sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+46 -3
View File
@@ -2,7 +2,6 @@ import { test, expect, type Page } from '@playwright/test';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { tmpdir } from 'node:os';
// ── Local-auth ADMIN USER MANAGEMENT E2E ──────────────────────────────────────
//
@@ -29,7 +28,8 @@ const require = createRequire(import.meta.url);
const repoRoot = resolve(__dirname, '..', '..');
// Same deterministic DB the webServer opens (see playwright.auth.config.ts).
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
const e2eTmp = process.env.E2E_AUTH_TMP;
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
// The bootstrap admin from config.e2e-auth.yaml.
@@ -167,10 +167,50 @@ test('admin can list users, promote a member to admin, and approve a pending use
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
test('system admin opens the event log and filters its results', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
const resourceId = `e2e-event-log-${Date.now()}`;
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (dbPath: string) => {
recordEvent: (input: Record<string, unknown>) => { id: number };
close?: () => void;
};
};
const repo = new Repository(DB_PATH);
try {
repo.recordEvent({
eventType: 'task.failed', resourceType: 'task', resourceId, result: 'failed',
dedupeKey: crypto.randomUUID(), correlationId: crypto.randomUUID(),
});
} finally {
repo.close?.();
}
await login(page, ADMIN.email, ADMIN.password);
await page.goto('/ui?page=users');
await page.getByRole('tab', { name: /イベントログ|Event log/ }).click();
await expect(page.getByRole('heading', { name: /イベントログ|Event log/ })).toBeVisible();
// 絞り込みはイベントログの領域の中だけを見る。上の帯にも「検索」という名前の
// ボタン(shell-search)があり、ページ全体から探すと 2 つ当たって落ちる。
// AdminEventLogPage の <section aria-labelledby="admin-event-log-title"> が
// この領域(role=region)。
const eventLog = page.getByRole('region', { name: /イベントログ|Event log/ });
await eventLog.getByLabel(/種別|Type/).selectOption('task.failed');
await eventLog.getByLabel(/リソースID|Resource ID/).fill(resourceId);
await eventLog.getByRole('button', { name: /検索|Search/ }).click();
const row = page.getByRole('row').filter({ hasText: resourceId });
await expect(row).toBeVisible();
await expect(row).toContainText('task.failed');
await expect(row).toContainText('failed');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// Negative/visibility: a regular member gets NO Users tab and is denied the
// admin API. The 403/401 is fetched via page.request (NOT navigation), so the
// navigation-scoped fatal tracker never observes the expected denial.
test('a non-admin member sees no Users tab and is denied /api/admin/users', async ({ page }) => {
test('a non-admin member sees no admin tabs and is denied admin APIs', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
// OUTSIDER is a plain 'user' that no test promotes, so the non-admin denial
@@ -180,10 +220,13 @@ test('a non-admin member sees no Users tab and is denied /api/admin/users', asyn
// The Users tab is admin+auth gated; a regular user never sees it.
await expect(page.getByTestId('nav-users')).toHaveCount(0);
await expect(page.getByRole('tab', { name: /イベントログ|Event log/ })).toHaveCount(0);
// Admin-only API is denied for a regular user (401 or 403 depending on guard).
const denied = await page.request.get('/api/admin/users');
expect([401, 403]).toContain(denied.status());
const eventLogDenied = await page.request.get('/api/admin/event-log');
expect(eventLogDenied.status()).toBe(403);
expect(fatalErrors, `fatal errors (DOM navigation only):\n${fatalErrors.join('\n')}`).toEqual([]);
});
+34
View File
@@ -0,0 +1,34 @@
/**
* ログアウトの道具(issue #1484)。
*
* ログイン直後の画面は、個人ワークスペースを自動で開くために
* `GET /api/local/spaces/:id/tool-policy` を投げる。これは一覧
* (`GET /api/local/spaces`)が返ってから始まる 2 往復目なので、テストが
* 次の利用者へ切り替えようとする頃にちょうど飛んでいることがある。
*
* ここで `/auth/logout` へそのまま移ると、次の順序が起こりうる。
*
* 1. 画面が tool-policy を投げる(この時点ではまだログイン済み)
* 2. サーバが `/auth/logout` を処理し、セッションを壊す
* 3. tool-policy に 401 が返る
* 4. `/auth/login` の表示が始まり、古い画面がようやく捨てられる
*
* 3 と 4 の間はわずか数ミリ秒だが、その間だけ**古い画面は生きたまま 401 を
* 受け取る**。spec が仕掛けた `page.on('response')` はそれを拾い、赤になる。
* コードを変えずに回し直すと緑になるのはこのためで、CI の走り 3312 が実際に
* これで落ちた(3313 は同じコードで緑)。
*
* 直し方は待ち時間を足すことではない。**合図を消す前に画面を畳む**——
* `about:blank` へ移せば画面の JavaScript はそこで止まり、セッションが死んで
* いる間に要求を出せる主体がいなくなる。競争そのものが無くなる。
*
* 逆順(先にログアウト、あとで画面を畳む)に戻すと、この 401 は再び起こる。
*/
import type { Page } from '@playwright/test';
export async function logout(page: Page) {
// 先に画面を畳む。ここを通さずに /auth/logout へ移ると、上のコメントの
// 3〜4 の隙間で古い画面が 401 を受け取る。
await page.goto('about:blank');
await page.goto('/auth/logout');
}
+25 -20
View File
@@ -2,7 +2,8 @@ import { test, expect, type Page } from '@playwright/test';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { tmpdir } from 'node:os';
import { openSpaceTab } from '../e2e/helpers/spaceTabs';
import { logout } from './helpers/session';
// ── Local-auth shared-space E2E ───────────────────────────────────────────────
//
@@ -28,7 +29,9 @@ const repoRoot = resolve(__dirname, '..', '..');
// independently — rather than importing the config — keeps the spec decoupled
// from Playwright's config-resolution internals while guaranteeing both open
// the SAME SQLite file.
const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db');
const e2eTmp = process.env.E2E_AUTH_TMP;
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
const ADMIN = { email: '[email protected]', password: 'AdminPass123!' };
const ALICE = { email: '[email protected]', password: 'AlicePass123!', name: 'Alice' };
@@ -55,12 +58,6 @@ const BENIGN_PATHS = new Set([
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
const BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
// The space members GET is auth-gated. During the logout→login handoff (test 3),
// the header avatar query can fire ONE request after the cookie is cleared but
// before the new session lands → a transient 401. Only a 401 (unauthenticated)
// is benign here; a 403 (authenticated-but-forbidden) would be a real authz bug
// and is NOT excused. The avatar assertion still proves the eventual 200.
const SPACE_MEMBERS_RE = /\/api\/local\/spaces\/[^/]+\/members$/;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
@@ -68,12 +65,10 @@ function trackFatalErrors(page: Page): string[] {
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
const transientMembers401 = res.status() === 401 && SPACE_MEMBERS_RE.test(pathname);
if (
!BENIGN_ASSET.test(pathname) &&
!BENIGN_PATHS.has(pathname) &&
!BENIGN_PATH_RE.test(pathname) &&
!transientMembers401
!BENIGN_PATH_RE.test(pathname)
) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
@@ -97,10 +92,6 @@ async function login(page: Page, email: string, password: string) {
expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login');
}
async function logout(page: Page) {
await page.goto('/auth/logout');
}
/** Open Spaces, create a case space with a unique title, select it, and return
* the detail locator + the resolved space id. Mirrors the auth-off helper. */
async function createAndOpenCaseSpace(page: Page, label: string) {
@@ -132,7 +123,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
/** Navigate the already-open space to 設定 → メンバー and return the panel. */
async function openMembersPanel(page: Page) {
await page.getByTestId('space-tab-settings').click();
await openSpaceTab(page, 'settings');
await page.getByTestId('space-settings-nav-members').click();
const panel = page.getByTestId('space-members-panel');
await expect(panel).toBeVisible({ timeout: 15_000 });
@@ -232,7 +223,11 @@ test('invite alice → member row + header avatars appear', async ({ page }) =>
await inviteMember(page, ALICE.name);
// Alice now appears as a member row in the panel.
const aliceRow = panel.locator('[data-testid^="space-member-"]').filter({ hasText: ALICE.name });
// 招待の一覧(space-member-picker)は除く。候補として同じ名前を出すので、
// 含めたままだと「行が出た」と言いながら一覧を見ているだけになりうる。
const aliceRow = panel
.locator('[data-testid^="space-member-"]:not([data-testid^="space-member-picker"])')
.filter({ hasText: ALICE.name });
await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 });
// AND the header avatar stack renders now that the space has >= 2 members.
@@ -259,7 +254,11 @@ test('alice sees + opens the shared space, avatars render', async ({ page }) =>
const { spaceId } = await createAndOpenCaseSpace(page, 'E2E協働');
const panel = await openMembersPanel(page);
await inviteMember(page, ALICE.name);
const aliceRow = panel.locator('[data-testid^="space-member-"]').filter({ hasText: ALICE.name });
// 招待の一覧(space-member-picker)は除く。候補として同じ名前を出すので、
// 含めたままだと「行が出た」と言いながら一覧を見ているだけになりうる。
const aliceRow = panel
.locator('[data-testid^="space-member-"]:not([data-testid^="space-member-picker"])')
.filter({ hasText: ALICE.name });
await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 });
// Switch to alice.
@@ -295,10 +294,16 @@ test('admin changes alice role then removes her', async ({ page }) => {
await inviteMember(page, ALICE.name);
// Resolve alice's member row + her userId from the row testid suffix
// (`space-member-{userId}`; the row wrapper is the first prefix match).
// (`space-member-{userId}`).
//
// 招待の一覧(space-member-picker)を必ず外すこと。あれも
// `space-member-` で始まる div で、中に候補として同じ名前が出る。追加した
// 直後は行がまだ描かれていないのに一覧だけ残っている瞬間があり、そこを掴むと
// userId が 'picker' になって、この後の space-member-role-picker が永遠に
// 見つからない(#1399。実際にこの並びで落ちた)。
const aliceRow = () =>
page.getByTestId('space-members-panel')
.locator('div[data-testid^="space-member-"]')
.locator('div[data-testid^="space-member-"]:not([data-testid^="space-member-picker"])')
.filter({ hasText: ALICE.name })
.first();
await expect(aliceRow()).toBeVisible({ timeout: 15_000 });
+5 -11
View File
@@ -3,7 +3,7 @@ import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { openSpaceTab } from '../e2e/helpers/spaceTabs';
// ── Local-auth SHARING-SCOPE E2E ──────────────────────────────────────────────
//
@@ -43,7 +43,8 @@ const require = createRequire(import.meta.url);
const repoRoot = resolve(__dirname, '..', '..');
// Same deterministic DB + worktree dir the webServer opens (playwright.auth.config.ts).
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
const e2eTmp = process.env.E2E_AUTH_TMP;
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
@@ -68,7 +69,6 @@ const BENIGN_PATHS = new Set([
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
const BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
const SPACE_MEMBERS_RE = /\/api\/local\/spaces\/[^/]+\/members$/;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
@@ -76,12 +76,10 @@ function trackFatalErrors(page: Page): string[] {
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
const transientMembers401 = res.status() === 401 && SPACE_MEMBERS_RE.test(pathname);
if (
!BENIGN_ASSET.test(pathname) &&
!BENIGN_PATHS.has(pathname) &&
!BENIGN_PATH_RE.test(pathname) &&
!transientMembers401
!BENIGN_PATH_RE.test(pathname)
) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
@@ -101,10 +99,6 @@ async function login(page: Page, email: string, password: string) {
expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login');
}
async function logout(page: Page) {
await page.goto('/auth/logout');
}
/** Open Spaces and select the seeded shared space; returns the detail locator. */
async function openSharedSpace(page: Page) {
await page.goto('/ui');
@@ -163,7 +157,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
}
async function openMembersPanel(page: Page) {
await page.getByTestId('space-tab-settings').click();
await openSpaceTab(page, 'settings');
await page.getByTestId('space-settings-nav-members').click();
const panel = page.getByTestId('space-members-panel');
await expect(panel).toBeVisible({ timeout: 15_000 });
+12 -7
View File
@@ -2,7 +2,7 @@ import { test, expect, type Page } from '@playwright/test';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { tmpdir } from 'node:os';
import { logout } from './helpers/session';
// ── Local-auth WORKSPACE-INVITE-LINK E2E ──────────────────────────────────────
//
@@ -41,7 +41,9 @@ const repoRoot = resolve(__dirname, '..', '..');
// builds it identically: <tmpdir>/maestro-e2e-auth/e2e-auth.db). Computing it
// independently keeps the spec decoupled from Playwright's config internals while
// guaranteeing both open the SAME SQLite file.
const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db');
const e2eTmp = process.env.E2E_AUTH_TMP;
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
const OWNER = { email: '[email protected]', password: 'OwnerPass123!', name: 'InviteOwner' };
const JOINER = { email: '[email protected]', password: 'JoinerPass123!', name: 'InviteJoiner' };
@@ -74,11 +76,18 @@ function trackFatalErrors(page: Page): string[] {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
const expectedInvitePreview404 = res.status() === 404 && INVITE_PREVIEW_RE.test(pathname);
// ログアウトした直後、次のログイン画面へ移る一瞬に /api/auth/me が 401 で
// 返ることがある。これは「今の利用者は誰か」を尋ねて「誰でもない」と答えられた
// だけで、不具合ではない(useAuthState はこの 401 を未ログインの合図として使う)。
// 出るかどうかは画面の入れ替わりとの競争で決まる。パスと状態を両方指定している
// ので、/api/auth/me が 500 を返せば今までどおり失敗として残る(#1399)。
const loggedOutAuthProbe401 = res.status() === 401 && pathname === '/api/auth/me';
if (
!BENIGN_ASSET.test(pathname) &&
!BENIGN_PATHS.has(pathname) &&
!BENIGN_PATH_RE.test(pathname) &&
!expectedInvitePreview404
!expectedInvitePreview404 &&
!loggedOutAuthProbe401
) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
@@ -100,10 +109,6 @@ async function login(page: Page, email: string, password: string) {
expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login');
}
async function logout(page: Page) {
await page.goto('/auth/logout');
}
/** Mint an invite for the seeded case space via the owner's authed page.request.
* Returns the captured token. */
async function createInvite(page: Page, role: 'viewer' | 'editor'): Promise<string> {
@@ -2,7 +2,6 @@ import { test, expect, type Page } from '@playwright/test';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { tmpdir } from 'node:os';
// ── M0: WORKSPACE FILE → AGENT INPUT E2E ─────────────────────────────────────
//
@@ -27,7 +26,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const repoRoot = resolve(__dirname, '..', '..');
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
const e2eTmp = process.env.E2E_AUTH_TMP;
if (!e2eTmp) throw new Error('E2E_AUTH_TMP must be set by playwright.auth.config.ts');
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
const USER = { email: '[email protected]', password: 'WsFile123!', name: 'WsFileUser' };
@@ -129,10 +129,12 @@ test('opening a fresh chat surfaces step 4 guidance: artifacts live in the outpu
// Open the Files tab of the opened chat, then switch to the output/ section.
// Brand-new chat → output/ is empty → the step 4 hint explains where the
// agent's artifacts will land. (The FileBrowser section switcher buttons are
// plain-text 'workspace'/'input'/'output'/'logs'.)
// agent's artifacts will land. The section switcher button is addressed by its
// testid: its label is the plain folder name 'output', and the breadcrumb root
// (task-files-breadcrumb-root) carries the same label, so a by-name lookup
// matches two elements.
await page.getByTestId('space-chat-tab-files').click();
await page.getByRole('button', { name: 'output', exact: true }).click();
await page.getByTestId('task-file-section-output').click();
await expect(page.getByTestId('output-empty-hint')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('output-empty-hint')).toContainText('output/');
});
+11
View File
@@ -0,0 +1,11 @@
import { cleanupOwnedRunDir } from '../scripts/e2e-run-dir.mjs';
export default function globalTeardown() {
// playwright の設定側が必ずセットする。未設定なら、この実行が作った
// 一時ディレクトリも無い。undefined のまま渡すと realpathSync が投げる。
const cacheRoot = process.env.E2E_CACHE_ROOT;
if (!cacheRoot) return;
cleanupOwnedRunDir({ cacheRoot, directory: process.env.E2E_RUN_TMP, prefix: process.env.E2E_RUN_PREFIX, token: process.env.E2E_RUN_TOKEN });
cleanupOwnedRunDir({ cacheRoot, directory: process.env.E2E_TMP, prefix: 'maestro-e2e-data-', token: process.env.E2E_DATA_TOKEN });
cleanupOwnedRunDir({ cacheRoot, directory: process.env.E2E_AUTH_TMP, prefix: 'maestro-e2e-auth-data-', token: process.env.E2E_DATA_TOKEN });
}
+6 -5
View File
@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test';
import { createRequire } from 'node:module';
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -20,8 +20,8 @@ import { fileURLToPath } from 'node:url';
//
// We cannot create an app + share link through the LLM-less UI, so we seed engine
// state directly into the throwaway DB + workspace dir (same handoff the
// tool-request spec uses): the DB path is written by playwright.config.ts to
// ui/.e2e-db-path, and the workspace dir is its sibling 'workspaces' folder
// tool-request spec uses): the DB path comes from the run-scoped E2E_TMP, and
// the workspace dir is its sibling 'workspaces' folder
// (E2E_TMP/{e2e.db, workspaces}). We create a space via the built Repository,
// drop an entry HTML under {worktree}/space/{id}/files/apps/{app}/index.html
// (matching spaceFilesDir + findEntryPath), and mint a share token. Then the
@@ -34,8 +34,9 @@ const repoRoot = resolve(__dirname, '..', '..');
// playwright.config.ts hands off DB_PATH = {E2E_TMP}/e2e.db and
// WORKTREE_DIR = {E2E_TMP}/workspaces (siblings). Derive the worktree dir from
// the DB path so both agree on the SAME temp tree the webServer booted with.
const dbPath = readFileSync(resolve(__dirname, '..', '.e2e-db-path'), 'utf-8').trim();
const e2eTmp = dirname(dbPath);
const e2eTmp = process.env.E2E_TMP;
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
const dbPath = join(e2eTmp, 'e2e.db');
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
const APP_NAME = 'e2e-public-app';
+50
View File
@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test';
import { openSpaceTab } from './helpers/spaceTabs';
const createCaseSpace = async (page: import('@playwright/test').Page) => {
await page.goto('/ui?page=spaces');
await page.getByTestId('create-space-btn').click();
const title = `E2E自動ルール-${Date.now()}`;
await page.getByTestId('space-title-input').fill(title);
await page.getByTestId('space-form-submit').click();
const row = page.getByTestId('space-rail')
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: title });
await expect(row).toBeVisible();
await row.click();
return await row.getAttribute('data-space-id') as string;
};
test('Auto Prompter: default OFF, retain rules while OFF, enable, and delete', async ({ page }) => {
const spaceId = await createCaseSpace(page);
await openSpaceTab(page, 'settings');
await page.getByTestId('space-settings-nav-auto-prompter').click();
const panel = page.getByTestId('space-auto-prompter-settings');
await expect(panel).toBeVisible();
const master = panel.getByRole('switch', { name: 'Auto Prompterを有効にする' });
await expect(master).toHaveAttribute('aria-checked', 'false');
await expect(panel).toContainText('オフにしても登録済みのルールは保持されます');
await panel.getByRole('button', { name: 'ルールを作成' }).click();
await panel.locator('#auto-prompter-template').selectOption('official_docs');
await panel.getByRole('button', { name: '保存' }).click();
await expect(panel.getByText('コマンドは公式資料を確認する', { exact: true })).toBeVisible();
const offState = await page.request.get(`/api/local/spaces/${encodeURIComponent(spaceId)}/auto-prompter`);
expect(offState.ok()).toBeTruthy();
const offPayload = await offState.json();
expect(offPayload).toMatchObject({ enabled: false });
expect(offPayload.rules).toHaveLength(1);
await master.click();
await expect(master).toHaveAttribute('aria-checked', 'true');
await expect.poll(async () => {
const response = await page.request.get(`/api/local/spaces/${encodeURIComponent(spaceId)}/auto-prompter`);
return (await response.json()).enabled;
}).toBe(true);
page.once('dialog', dialog => void dialog.accept());
await panel.getByRole('button', { name: '「コマンドは公式資料を確認する」を削除' }).click();
await expect(panel.getByText('コマンドは公式資料を確認する', { exact: true })).toHaveCount(0);
});
+66 -1
View File
@@ -1,4 +1,7 @@
import { test, expect, type Page } from '@playwright/test';
import Database from 'better-sqlite3';
import { resolve } from 'node:path';
import { openSpaceTab } from './helpers/spaceTabs';
// E2E smoke for the V2 cross-space calendar (top-bar "カレンダー" tab).
//
@@ -45,6 +48,34 @@ function e2eLocalToday(): string {
return `${d.getFullYear()}-${mm}-${dd}`;
}
const ADJACENT_MONTH_CLOCK = new Date('2026-07-15T12:00:00.000Z');
const ADJACENT_MONTH_FIRST = '2026-06-28';
const ADJACENT_MONTH_LAST = '2026-08-08';
async function seedTaskDate(page: Page, spaceId: string, date: string, body: string): Promise<number> {
const created = await page.request.post('/api/local/tasks', {
data: { body, piece: 'chat', spaceId, workspaceMode: 'persistent' },
});
expect(created.ok()).toBeTruthy();
const taskId = (await created.json()).task.id as number;
const e2eTmp = process.env.E2E_TMP;
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
const db = new Database(resolve(e2eTmp, 'e2e.db'));
db.pragma('busy_timeout = 8000');
db.prepare('UPDATE local_tasks SET created_at = ?, updated_at = ? WHERE id = ?')
.run(`${date}T12:00:00.000Z`, `${date}T12:00:00.000Z`, taskId);
db.close();
return taskId;
}
async function seedEvent(page: Page, spaceId: string, date: string, title: string, endDate?: string): Promise<number> {
const created = await page.request.post(`/api/local/spaces/${spaceId}/calendar/events`, {
data: { date, end_date: endDate, title },
});
expect(created.ok()).toBeTruthy();
return (await created.json()).id as number;
}
async function createAndOpenCaseSpace(page: Page, label: string) {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
@@ -72,7 +103,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
/** Add an event for today in the currently-open space's calendar tab. */
async function addTodayEvent(page: Page, title: string) {
await page.getByTestId('space-tab-calendar').click();
await openSpaceTab(page, 'calendar');
await expect(page.getByTestId('space-cal-grid')).toBeVisible();
const today = e2eLocalToday();
await page.getByTestId(`space-cal-day-${today}`).click();
@@ -144,3 +175,37 @@ test('cross calendar: a space with activity shows its dot + day panel groups by
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
test('cross calendar: adjacent month days show badges, details, and spanning bars', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.clock.setFixedTime(ADJACENT_MONTH_CLOCK);
const { caseTitle, spaceId } = await createAndOpenCaseSpace(page, 'E2E横断隣接月');
const first = ADJACENT_MONTH_FIRST;
const last = ADJACENT_MONTH_LAST;
const firstTaskId = await seedTaskDate(page, spaceId, first, `横断先頭タスク-${Date.now()}`);
const lastTaskId = await seedTaskDate(page, spaceId, last, `横断末尾タスク-${Date.now()}`);
const firstEvent = `横断先頭予定-${Date.now()}`;
const lastEvent = `横断末尾予定-${Date.now()}`;
await seedEvent(page, spaceId, first, firstEvent);
await seedEvent(page, spaceId, last, lastEvent);
const spanningEvent = `横断両境界予定-${Date.now()}`;
const spanningId = await seedEvent(page, spaceId, first, spanningEvent, last);
await page.getByTestId('nav-calendar').click();
const firstCell = page.getByTestId(`cross-cal-day-${first}`);
const lastCell = page.getByTestId(`cross-cal-day-${last}`);
await expect(firstCell.getByTestId(`cross-cal-dot-${spaceId}`)).toHaveAttribute('title', /タスク.*1.*予定.*2/);
await expect(lastCell.getByTestId(`cross-cal-dot-${spaceId}`)).toHaveAttribute('title', /タスク.*1.*予定.*2/);
await expect(page.getByTestId(`cross-cal-bar-${spanningId}`)).toHaveCount(6);
await firstCell.click();
let group = page.getByTestId(`cross-cal-space-${spaceId}`);
await expect(group.getByText(caseTitle, { exact: true })).toBeVisible();
await expect(group.getByTestId(`cross-cal-task-${firstTaskId}`)).toBeVisible();
await expect(group.getByText(firstEvent, { exact: true })).toBeVisible();
await expect(group.getByText(spanningEvent, { exact: true })).toBeVisible();
await lastCell.click();
group = page.getByTestId(`cross-cal-space-${spaceId}`);
await expect(group.getByTestId(`cross-cal-task-${lastTaskId}`)).toBeVisible();
await expect(group.getByText(lastEvent, { exact: true })).toBeVisible();
await expect(group.getByText(spanningEvent, { exact: true })).toBeVisible();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
+27
View File
@@ -0,0 +1,27 @@
/**
* ワークスペースの横帯のタブを開くための道具(issue #1360 段 1)。
*
* アプリ・カレンダー・自動実行・設定の 4 つは「その他」の中へ畳んだので、押す前に
* その入口を開く必要がある。目印(`space-tab-apps` など)は畳む前と同じなので、
* ここを通せば呼ぶ側は今までどおり id だけを指定すればよい。
*
* チャットとファイルは横帯に出たままなので、この道具は要らない。
*/
import { expect, type Page } from '@playwright/test';
/** 「その他」の中に畳んである画面。 */
export type OverflowSpaceTab = 'apps' | 'calendar' | 'schedules' | 'settings';
/**
* 畳んである画面を開く。「その他」を押してからメニューの項目を押す。
* 押したあとメニューは閉じる(項目を選ぶと閉じる作り)。
*/
export async function openSpaceTab(page: Page, tab: OverflowSpaceTab): Promise<void> {
const more = page.getByTestId('space-tab-more');
await expect(more).toBeVisible();
await more.click();
const item = page.getByTestId(`space-tab-${tab}`);
await expect(item).toBeVisible();
await item.click();
await expect(page.getByTestId('space-tab-more-menu')).toHaveCount(0);
}
+102
View File
@@ -0,0 +1,102 @@
import { expect, test } from '@playwright/test';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
const e2eTmp = process.env.E2E_TMP;
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
const dbPath = resolve(e2eTmp, 'e2e.db');
type SeedRepository = {
ensureLocalUser: () => void;
createUser: (input: Record<string, unknown>) => { id: string };
createSpace: (input: Record<string, unknown>) => Promise<{ id: string }>;
createLocalTask: (input: Record<string, unknown>) => Promise<{ id: number }>;
recordEvent: (input: Record<string, unknown>) => { id: number };
createNotificationItem: (input: Record<string, unknown>) => { id: string };
createAgentReminder: (input: Record<string, unknown>) => { id: string };
close: () => void;
};
const openRepository = (): SeedRepository => {
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (path: string) => SeedRepository;
};
const repository = new Repository(dbPath);
// The notification_items recipient FK must not depend on the web server
// having completed its no-auth bootstrap before this fixture runs.
repository.ensureLocalUser();
return repository;
};
test('notification bell shows unread inbox and opens the notification center', async ({ page }) => {
const title = `E2E未読通知-${Date.now()}`;
const repo = openRepository();
try {
const space = await repo.createSpace({ kind: 'case', title: `E2E通知-${Date.now()}`, ownerId: 'local', visibility: 'private' });
const task = await repo.createLocalTask({ title, body: '', ownerId: 'local', spaceId: space.id });
const event = repo.recordEvent({
eventType: 'task.failed', resourceType: 'task', resourceId: `e2e-${Date.now()}`,
result: 'failed', dedupeKey: crypto.randomUUID(), correlationId: crypto.randomUUID(),
});
repo.createNotificationItem({
eventId: event.id, recipientUserId: 'local', category: 'failed', title,
summary: 'E2E notification summary', spaceId: space.id,
resourceType: 'job', resourceId: `e2e-${event.id}`, occurredAt: new Date().toISOString(),
metadata: { localTaskId: task.id },
});
} finally {
repo.close();
}
await page.goto('/ui');
const seededInbox = await page.request.get('/api/notifications/inbox?limit=5');
expect(seededInbox.ok()).toBeTruthy();
expect((await seededInbox.json() as { items: Array<{ title: string }> }).items.map(item => item.title)).toContain(title);
const bell = page.getByTestId('notification-bell-trigger');
await expect(bell).toBeVisible();
await expect(page.getByTestId('notification-bell-badge')).toBeVisible();
await bell.click();
const panel = page.getByTestId('notification-bell-panel');
await expect(panel).toBeVisible();
await expect(panel.getByRole('button', { name: new RegExp(title) })).toBeVisible();
await panel.getByRole('button', { name: /すべて表示|View all/ }).click();
await expect(page).toHaveURL(/[?&]page=notifications(&|$)/);
await expect(page.getByRole('heading', { name: /通知センター|Notification center/ })).toBeVisible();
const row = page.getByRole('button', { name: new RegExp(title) });
await expect(row).toBeVisible();
await expect(row.getByText(/未読|Unread/)).toBeAttached();
await row.click();
await expect(page).toHaveURL(/[?&]space=[^&]+.*[?&]chat=\d+/);
await expect(page.getByTestId('notification-bell-badge')).toHaveCount(0);
});
test('upcoming reminders show only reminders assigned to the current user', async ({ page }) => {
const mine = `E2E本人の予定-${Date.now()}`;
const other = `E2E他人の予定-${Date.now()}`;
const repo = openRepository();
try {
const otherUser = repo.createUser({
email: `other-reminder-${crypto.randomUUID()}@example.test`, name: 'Other reminder owner',
role: 'user', status: 'active',
});
repo.createAgentReminder({ ownerId: 'local', title: mine, body: mine, dueAt: '2099-01-02T03:04:00.000Z', timezoneId: 'UTC' });
repo.createAgentReminder({ ownerId: otherUser.id, title: other, body: other, dueAt: '2099-01-02T04:04:00.000Z', timezoneId: 'UTC' });
} finally {
repo.close();
}
await page.goto('/ui?page=notifications&notificationTab=upcoming');
await expect(page.getByRole('tab', { name: /予定|Upcoming/ })).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(mine, { exact: true })).toBeVisible();
await expect(page.getByText(other, { exact: true })).toHaveCount(0);
const response = await page.request.get('/api/notifications/upcoming');
expect(response.ok()).toBeTruthy();
const body = await response.json() as { items: Array<{ title: string }> };
expect(body.items.map(item => item.title)).toContain(mine);
expect(body.items.map(item => item.title)).not.toContain(other);
});
+20 -12
View File
@@ -12,12 +12,20 @@ import { test, expect, type Page } from '@playwright/test';
// create a custom piece from the sidebar, then edit + save it through the editor.
//
// Selector grounding (read from the real components):
// - PiecesPage.tsx: the "+" button next to the "Custom Pieces" section opens an
// inline input with placeholder="piece-name"; Enter creates the piece via
// POST /api/pieces and selects it.
// - PieceEditor.tsx: a Visual/YAML mode toggle ("Visual" / "YAML" literal
// buttons), a <textarea> for the YAML, and a footer "Save" button (enabled
// only when dirty). These are English literals, not i18n keys.
// - PiecesPage.tsx: the "+" button next to the custom-workflows section opens
// an inline input with placeholder="piece-name"; Enter creates the piece via
// POST /api/pieces and selects it. The two section headings USED to be
// hardcoded English ("Default Workflows" / "Custom Workflows"); issue #1430
// moved them behind i18n (pieces.json sidebar.defaultSection/customSection).
// This runner's locale is ja-JP, so every assertion on those headings has to
// accept both languages -- the same shape as spaces.spec.ts uses for the
// Default/Custom section labels. An English-only literal here does not fail
// loudly: getByText simply never matches, and a toHaveCount(0) assertion
// passes for the wrong reason.
// - PieceEditor.tsx: a Visual/YAML mode toggle, a <textarea> for the YAML, and
// a footer "Save" button (enabled only when dirty). "YAML" and "Save" are
// still literals; "Visual", "Delete" and the read-only badge are i18n
// (settings.json pieceEditor.*, common.json delete), so match both languages.
// Backend state is verified through GET /api/pieces?source=custom — the same
// endpoint the page reads.
@@ -56,9 +64,9 @@ test('create a custom piece from the sidebar, then edit + save it via the editor
await page.goto('/ui?page=pieces');
await expect(page).toHaveURL(/[?&]page=pieces(&|$)/);
// The "Custom Pieces" section + its "+" create affordance render. The "+" is
// The custom-workflows section + its "+" create affordance render. The "+" is
// the only button in that section header (title=newPieceTitle).
await expect(page.getByText('Custom Pieces', { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/^(Custom Workflows|カスタムワークフロー)$/)).toBeVisible({ timeout: 15_000 });
const createToggle = page.locator('button[title]').filter({ hasText: '+' }).first();
await createToggle.click();
@@ -139,17 +147,17 @@ test('a built-in piece opens editable for admin (no Delete, Save appears once di
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=pieces');
await expect(page.getByText('Default Pieces', { exact: false })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/^(Default Workflows|標準ワークフロー)$/)).toBeVisible({ timeout: 15_000 });
// Open the built-in 'chat' piece from the Default Pieces section.
// Open the built-in 'chat' piece from the default-workflows section.
await page.getByRole('button', { name: /chat/i }).first().click();
// No readonly badge (this session is admin) and no Delete (built-ins are
// never deletable), but the editable footer with Save is present.
const saveBtn = page.getByRole('button', { name: 'Save' });
await expect(saveBtn).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/read-?only/i)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0);
await expect(page.getByText(/read-?only|読み取り専用/i)).toHaveCount(0);
await expect(page.getByRole('button', { name: /^(Delete|削除)$/ })).toHaveCount(0);
await expect(saveBtn).toBeDisabled();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
+71 -17
View File
@@ -1,25 +1,33 @@
import { test, expect, type Page } from '@playwright/test';
import { openSpaceTab } from './helpers/spaceTabs';
// ── Schedules (scheduled task CRUD) E2E ───────────────────────────────────────
// ── Automations (scheduled task CRUD) E2E ─────────────────────────────────────
//
// REQUIRES `npm run test:e2e` + a running server (ui/playwright.config.ts boots
// the real orchestrator with auth OFF → synthetic 'local' user). This does NOT
// run in the sandbox — there is no live server here.
//
// The Schedules page (page=schedules) is NOT auth-gated (NAV_ITEMS: schedules
// requiresAuth:false). The cronForm lib is partially unit-covered; this proves
// the create/list/edit UI wiring that has no e2e.
// WHERE the page lives: commit e6733e58 retired the top-level Automations tab
// and moved automations inside each workspace — `?page=schedules` now falls back
// to the spaces view, so the old `page.goto('/ui?page=schedules')` never reached
// the page and both tests in this file had been failing since (issue #1338). The
// screen itself is unchanged: SpaceDetail renders the SAME SchedulesPage
// component under its 自動実行 tab, which lives in the 「その他」 overflow menu
// (helpers/spaceTabs.ts). So this spec drives it there instead, and the
// selectors below still hold. Neither the workspaces view nor the automations
// screen is auth-gated. The cronForm lib is partially unit-covered; this proves
// the create/list/edit UI wiring that has no other e2e.
//
// Selector grounding (read from SchedulesPage.tsx + i18n locales en/ja):
// - List: a "New schedule" / "新しいスケジュール" button (list.new) opens the editor.
// - List: a "New automation" / "新しい自動実行" button (list.new) opens the editor.
// - Editor: a Title input (placeholder "Weekly news roundup" / "週次ニュースまとめ"),
// a Prompt textarea (placeholder "Enter the prompt to run" / "実行するプロンプトを入力"),
// and a submit button "Create"/"作成" (gated on a non-empty prompt body).
// - Each schedule renders as a list <button> showing its title.
// Because these controls are i18n-text driven (no test-ids) and the runner's
// browser locale decides en vs ja, text selectors below use bilingual regexes.
// Backend state is verified through GET /api/scheduled-tasks — the same endpoint
// the page reads.
// Backend state is verified through GET /api/scheduled-tasks?spaceId=… — the
// same endpoint the space-scoped page reads.
const BENIGN_PATHS = new Set([
'/api/auth/me',
@@ -30,6 +38,11 @@ const BENIGN_PATHS = new Set([
'/api/ssh/connections',
]);
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
// The workspace opens on its chat tab before we reach 自動実行; those two probes
// 404 on a workspace with no console session / browser session (same allowlist
// as spaces.spec.ts and calendar.spec.ts).
const BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
@@ -37,7 +50,7 @@ function trackFatalErrors(page: Page): string[] {
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname) && !BENIGN_PATH_RE.test(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
@@ -45,23 +58,63 @@ function trackFatalErrors(page: Page): string[] {
return fatalErrors;
}
/**
* Create a case workspace and open it. Returns its id so the spec can scope the
* backend reads to it (mirrors the same helper in calendar.spec.ts).
*/
async function createAndOpenCaseSpace(page: Page, label: string): Promise<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('space-form-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, 'created space id').toBeTruthy();
await caseRow.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
return spaceId as string;
}
/**
* Reopen an existing workspace's 自動実行 tab from a cold load. The open tab is
* component state, not a URL param, so the reload lands on 会話 and the overflow
* menu has to be walked again.
*/
async function reopenSpaceSchedules(page: Page, spaceId: string): Promise<void> {
await page.goto(`/ui?space=${encodeURIComponent(spaceId)}`);
await expect(page.getByTestId('space-detail')).toBeVisible();
await openSpaceTab(page, 'schedules');
}
// Bilingual matchers (the runner's browser locale picks en or ja; the harness
// pins no localStorage language, so navigator decides).
const NEW_SCHEDULE = /New schedule|新しいスケジュール/;
const NEW_AUTOMATION = /New automation|新しい自動実行/;
const SUBMIT_CREATE = /^(Create|作成)$/;
const TITLE_PLACEHOLDER = /Weekly news roundup|週次ニュースまとめ/;
const PROMPT_PLACEHOLDER = /Enter the prompt to run|実行するプロンプトを入力/;
const scheduleTitle = `e2e-schedule-${Date.now()}`;
test('create a scheduled task via the UI, then see it listed and persisted', async ({ page }) => {
test('create an automation via the UI, then see it listed and persisted', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=schedules');
await expect(page).toHaveURL(/[?&]page=schedules(&|$)/);
const spaceId = await createAndOpenCaseSpace(page, 'E2E自動実行');
await openSpaceTab(page, 'schedules');
// Open the editor.
await page.getByRole('button', { name: NEW_SCHEDULE }).first().click();
await page.getByRole('button', { name: NEW_AUTOMATION }).first().click();
// Fill the editor: title + prompt (the submit button is gated on a non-empty
// prompt body). Default scheduleType is 'daily', so no extra cron input needed.
@@ -80,7 +133,7 @@ test('create a scheduled task via the UI, then see it listed and persisted', asy
let createdId = 0;
await expect
.poll(async () => {
const res = await page.request.get('/api/scheduled-tasks');
const res = await page.request.get(`/api/scheduled-tasks?spaceId=${encodeURIComponent(spaceId)}`);
if (!res.ok()) return [];
const data = (await res.json()) as Array<{ id: number; title: string; cronExpression?: string }> | { tasks?: Array<{ id: number; title: string }> };
const list = Array.isArray(data) ? data : (data.tasks ?? []);
@@ -101,7 +154,7 @@ test('create a scheduled task via the UI, then see it listed and persisted', asy
expect(patched.ok(), 'edit schedule title').toBeTruthy();
// The list reflects the edit after a fresh load (refetches /api/scheduled-tasks).
await page.goto('/ui?page=schedules');
await reopenSpaceSchedules(page, spaceId);
await expect(page.getByRole('button', { name: new RegExp(editedTitle) })).toBeVisible({ timeout: 15_000 });
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
@@ -112,8 +165,9 @@ test('create a scheduled task via the UI, then see it listed and persisted', asy
test('the create button stays disabled until a prompt is entered', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=schedules');
await page.getByRole('button', { name: NEW_SCHEDULE }).first().click();
await createAndOpenCaseSpace(page, 'E2E自動実行ガード');
await openSpaceTab(page, 'schedules');
await page.getByRole('button', { name: NEW_AUTOMATION }).first().click();
// With only a title (no prompt body) the submit is disabled.
await page.getByPlaceholder(TITLE_PLACEHOLDER).fill('no-prompt-schedule');
+252
View File
@@ -0,0 +1,252 @@
/**
* 「コマンドの外部通信あり」の印を、幅を振って実測する(PR #1374 レビュー P2-B)。
*
* この印は**絶対に切り落とさない**——設定を開かない閲覧者に、いま外へ出られる状態か
* どうかを見せるためだけに出しているので、「🌐 コマンドの」で千切れたら意味が無い。
*
* それを守っているのが余白の算数だけだった時期がある。狭い画面で切れないのは
* **たまたま余白が足りていたから**で、名札が数十ピクセル伸びるだけで元の不具合が
* 戻った(直す前のコードを日本語・430px で測った値: +50px で 1.000、+60px で 0.935。
* 切れはじめるのはこの 2 点のあいだ。中間の 1 点は測っていないので書かない)。
* いまは組み方で守っている(`SpaceDetail` のタブ群=横に流れる箱、印=`shrink-0`)が、
* jsdom は幅を測れないので、**そこが効いていることは実機でしか確かめられない。**
*
* ここで見るのは 3 つ。
* 1. どの幅でも、上の帯の札が押し込まれない(この PR の存在理由)
* 2. どの幅でも、印は 1 ピクセルも切れない(見えている割合が 1.00)
* 3. 上の帯の札が伸びても切れない(翻訳が長くなる・札が増える、を先取りする)
*
* 1 が要る理由: この PR は「タブの位置が動かないこと」のために書かれている。その位置を
* 守っていた `shrink-0` を、印を守るために外してタブを縮む側へ回した。**守る相手を
* 取り替えたのに網が印しか見ていない**状態だったのを埋める(レビュー P2-3)。
*
* ついでに、選んでいるファイルの札の下地が**本当に塗られている**ことも見る。ここは
* 一度、ビルド後に 1 行も出ないクラス(不透明度つきの CSS 変数)で塗ったつもりに
* なっていた(レビュー P2-A)。透明かどうかは実機の計算値でしか分からない。
*/
import { test, expect, type Page } from '@playwright/test';
/** 印を必ず出す。実際の値は隔離環境の有無で変わるので、応答を差し替えて固定する。 */
async function forceNetworkBadge(page: Page): Promise<void> {
await page.route('**/api/local/spaces/*/tool-policy', async (route) => {
if (route.request().method() !== 'GET') return route.fallback();
const res = await route.fetch();
const body = await res.json();
await route.fulfill({
response: res,
json: {
...body,
sandboxNetwork: {
workspace: true,
global: true,
effective: true,
source: 'workspace',
sandboxMode: 'always',
bwrapAvailable: true,
bashUnrestricted: false,
},
},
});
});
}
/** 案件ワークスペースを 1 つ作って開く。 */
async function createAndOpenCaseSpace(page: Page): Promise<void> {
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 title = `帯の印-${Date.now()}`;
await titleInput.fill(title);
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const row = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: title });
await expect(row).toBeVisible();
await row.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
/**
* 印のうち、帯の中に見えている割合。1 = 全部見えている。
* 帯(ヘッダー行)の外へはみ出した分は、画面では切り取られて読めない。
*/
async function badgeVisibleRatio(page: Page): Promise<{ ratio: number; width: number }> {
return page.evaluate(() => {
const badge = document.querySelector('[data-testid="sandbox-network-badge"]');
if (!badge) throw new Error('印が出ていない');
const row = document.querySelector('[data-testid="space-tab-group"]')!.parentElement!;
const rr = row.getBoundingClientRect();
const br = badge.getBoundingClientRect();
const seen = Math.max(0, Math.min(br.right, rr.right) - Math.max(br.left, rr.left));
return { ratio: Number((seen / br.width).toFixed(3)), width: Number(br.width.toFixed(1)) };
});
}
/**
* 上の帯の札 3 枚の位置と、札を入れた箱がはみ出しているか。
*
* **画面の左端からの座標では見ない。**幅で切り替わる場所が 2 つあって、そこでは
* 座標が動くのが正しい(実測):
* - 640px(`sm`)でファイルの札に文字が出る → 札の幅 34 → 96、その右の
* 「その他」が 62px 右へ動く
* - 768px でチャット一覧の列が出る → 帯ごと右へ寄る(チャットの札 16 → 272)
* どちらも狭くなって削られたのではなく、**別の見た目に切り替わった**だけ。
*
* この PR が守るのは「札が押し込まれて動かないこと」なので、タブ群の左端からの
* 差で見る。差が変わる=札どうしが詰められたか、箱がはみ出して切り取られたか。
* `clipped` は箱がはみ出している状態そのもの(切り取られた分は横になぞらないと出ない)。
*/
async function tabOffsets(page: Page): Promise<Record<string, number | boolean>> {
return page.evaluate(() => {
const find = (testid: string) => {
const el = document.querySelector(`[data-testid="${testid}"]`);
if (!el) throw new Error(`${testid} が出ていない`);
return el;
};
const group = find('space-tab-group') as HTMLElement;
const box = group.firstElementChild as HTMLElement;
const left = (testid: string) => find(testid).getBoundingClientRect().left;
const chat = left('space-tab-chat');
const round = (n: number) => Number(n.toFixed(1));
return {
chat: round(chat - group.getBoundingClientRect().left),
files: round(left('space-tab-files') - chat),
more: round(left('space-tab-more') - chat),
clipped: box.scrollWidth > box.clientWidth,
};
});
}
/** 見た目が切り替わる境目。ここを跨ぐと札の並びが変わるのが正しい。 */
const FILES_LABEL_BREAKPOINT = 640;
/**
* 幅を変えたら、測る前に 1 フレーム置く。変えた直後に測ると、md の境目(768px)を
* 跨いだ瞬間の**崩れた 1 フレーム**を掴むことがある(レビュー担当の掃引で 4 回に 1 回、
* タブ群の幅 0・印の割合 0.535 が観測された。**こちらの実行では未再現**——待つように
* 直す前後で 768px を跨いだ掃引を何度か回したが、いずれも綺麗だった)。
* 利用者に見えるのは窓を跨いでリサイズした 1 フレームだけなので不具合ではないが、
* 待たずに測る網は、いつか CI で理由の分からない赤を出す(レビュー P3-6)。
*/
async function resizeAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 720 });
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
/** 上の帯の札を、翻訳が長くなったのと同じだけ横に伸ばす。0 で元に戻す。 */
async function growTabLabels(page: Page, extraPx: number): Promise<void> {
await page.evaluate((px) => {
document.getElementById('e2e-tab-growth')?.remove();
if (px <= 0) return;
const style = document.createElement('style');
style.id = 'e2e-tab-growth';
// 札 3 つに均等に配る(チャット・ファイル・その他)。
style.textContent =
`[data-testid="space-tab-group"] button { margin-inline-end: ${px / 3}px !important; }`;
document.head.appendChild(style);
}, extraPx);
}
const WIDTHS = [320, 375, 429, 430, 480, 640, 768, 900, 1024, 1440];
test.describe('ワークスペースの帯: 外部通信の印は切れない', () => {
test('どの幅でも、印は 1 ピクセルも切れない', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
await expect(page.getByTestId('sandbox-network-badge')).toBeVisible();
const seen = new Map<string, Record<string, number | boolean>>();
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
const { ratio, width: badgeWidth } = await badgeVisibleRatio(page);
expect(badgeWidth, `${width}px で印の幅が 0`).toBeGreaterThan(0);
expect(ratio, `${width}px で印が切れた`).toBe(1);
// 札の並びは、境目を跨がないかぎりどの幅でも同じでなければならない。
const key = width < FILES_LABEL_BREAKPOINT ? '狭' : '広';
const now = await tabOffsets(page);
expect(now.clipped, `${width}px で札の箱がはみ出した`).toBe(false);
const first = seen.get(key);
if (first) expect(now, `${width}px で上の帯の札が押し込まれた`).toEqual(first);
else seen.set(key, now);
}
});
test('上の帯の札が伸びても、印は切れない(翻訳が長くなる分の先取り)', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
await expect(page.getByTestId('sandbox-network-badge')).toBeVisible();
// 段は直す前のコードの実測に合わせて選ぶ。+50px までは直す前でも 1.000 で、
// 430px で初めて欠けるのは +60px(0.935)。ここを踏まないと網の目が粗すぎて、
// 組み方を書き戻しても +100px まで赤くならない。
for (const extra of [25, 50, 60, 100]) {
await growTabLabels(page, extra);
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
const { ratio } = await badgeVisibleRatio(page);
expect(ratio, `札を +${extra}px 伸ばした ${width}px で印が切れた`).toBe(1);
}
}
await growTabLabels(page, 0);
});
test('印の文字は、切れるのではなく丸ごと消える', async ({ page }) => {
await forceNetworkBadge(page);
await createAndOpenCaseSpace(page);
const badge = page.getByTestId('sandbox-network-badge');
await expect(badge).toBeVisible();
// 430px 以上では文が出る。それより狭いと絵だけになるが、読み上げ名は変わらない。
await resizeAndSettle(page, 430);
const wide = await badge.evaluate((el) => el.getBoundingClientRect().width);
const ariaWide = await badge.getAttribute('aria-label');
await resizeAndSettle(page, 429);
const narrow = await badge.evaluate((el) => el.getBoundingClientRect().width);
expect(narrow).toBeLessThan(wide);
expect(await badge.getAttribute('aria-label')).toBe(ariaWide);
expect(await badge.getAttribute('title')).toBe(ariaWide);
});
});
test.describe('ワークスペースの帯: 選んでいるファイルの札', () => {
test('選んでいるあいだ、下地が本当に塗られている', async ({ page }) => {
await createAndOpenCaseSpace(page);
const files = page.getByTestId('space-tab-files');
await files.click();
await expect(files).toHaveAttribute('aria-current', 'true');
// 札には `transition-colors`(150ms)が掛かっている。選んでいない状態の下地は
// 指定なし=透明なので、印が付いた直後に読むと**移り始めの透明**が返る。それは
// このテストが探している「1 行も CSS が出ていない」状態と計算値で区別できない
// (実測: CI で同じ中身が緑・緑・赤と揺れた。#1407)。落ち着くまで読み直す。
//
// 透明が返るのは一瞬ではない。移り変わりは最初の描画が来るまで始まらないので、
// 手元の実測でおよそ 25 ミリ秒続いた。CI が詰まっていればもっと伸びる。
//
// 移り変わりを消して直さないこと ── 網の都合で利用者に見える動きを変えることになる。
//
// 罫線はここで見ない。選んでいないときの罫線は `border-hairline`(#e4e4e7)で、
// **透明を一度も通らない**。上の理屈が当てはまらないので、待って読み直す意味が無い。
// 「塗られていること」を罫線で確かめる断言も、選んでいない側から不透明なので、
// 塗り分けの有無を切り分けられない。落ちない断言は守れているように見えるだけ。
const TRANSPARENT = /rgba\(0,\s*0,\s*0,\s*0\)|transparent/;
const bgOf = () => files.evaluate((el) => getComputedStyle(el).backgroundColor);
await expect.poll(bgOf, { message: '下地が塗られない' }).not.toMatch(TRANSPARENT);
});
});
+281
View File
@@ -0,0 +1,281 @@
/**
* 狭い画面で、キーボードの焦点が帯の外にある操作子を引き込むこと(issue #1379)。
*
* ワークスペースの帯の右側(名前・鉛筆・顔・ゴミ箱)は横に流れる箱に入っている。
* 指やマウスなら横になぞって出せるが、**焦点を移しただけでは箱が動かず**、削除
* ボタンが帯からはみ出したまま押される画面があった(430 ピクセル前後。はみ出し
* 始める幅は文字の計量で機械ごとに前後する)。
*
* ここで見るのは 3 つ。
* 1. Tab で焦点を移したら、**手で目一杯なぞったときと同じところまで**見える
* 2. 縦は動かない(`block: 'nearest'`。外すとページ全体が縦に飛ぶ)
* 3. **マウスで押したときは箱が動かない**(押した瞬間にずれると押し間違える)
*
* **「割合 1.00」を基準にしない。**流れる箱の内側の幅が削除ボタンより狭い画面が
* あり(実測で箱 27px / ボタン 28px)、そこでは指でどれだけなぞっても 1 にならない。
* 中身の幅はフォントの計量で機械ごとに変わるので、絶対値を焼き込むと別の機械で
* 嘘になる。基準は**利用者が手で到達できるいちばん良い位置**に取る。
*
* jsdom は幅を測れないので、1 と 3 の「本当に見えているか」は実機でしか分からない。
* 配線(帯の行に処理が付いているか)は SpaceDetail.focusScroll.test.tsx が見る。
*/
import { test, expect, type Page } from '@playwright/test';
/** 案件ワークスペースを 1 つ作って開く(削除ボタンは案件ワークスペースにだけ出る)。 */
async function createAndOpenCaseSpace(page: Page): Promise<void> {
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 title = `帯の焦点-${Date.now()}`;
await titleInput.fill(title);
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const row = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: title });
await expect(row).toBeVisible();
await row.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
/** 幅を変えたら 1 フレーム置いてから測る(space-header-badge.spec.ts と同じ理由)。 */
async function resizeAndSettle(page: Page, width: number): Promise<void> {
await page.setViewportSize({ width, height: 720 });
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
}),
);
}
interface Measured {
/** 削除ボタンのうち、流れる箱の中に見えている割合。1 = 全部見えている。 */
ratio: number;
/** 横に流れる箱をどれだけなぞった状態か。 */
scrollLeft: number;
/** これ以上なぞれない位置(`scrollWidth - clientWidth`)。 */
maxScrollLeft: number;
/** 帯の上端(縦に飛んでいないかを見る)。 */
rowTop: number;
/** ページ全体の縦位置。 */
pageScrollTop: number;
/** いま焦点があるのが削除ボタンか。 */
deleteFocused: boolean;
/** 削除ボタンの右端(はみ出し量を報告に書くため)。 */
right: number;
}
async function measure(page: Page): Promise<Measured> {
return page.evaluate(() => {
const btn = document.querySelector('[data-testid="space-delete"]');
if (!btn) throw new Error('削除ボタンが出ていない');
const box = document.querySelector('[data-testid="space-header-meta"]') as HTMLElement;
const row = box.parentElement!;
const rr = row.getBoundingClientRect();
// 切り取られるのは**流れる箱の縁**であって、帯の行の縁ではない(行には右余白が
// あるので、行で測ると数ピクセルぶん甘く出る)。
const cr = box.getBoundingClientRect();
const br = btn.getBoundingClientRect();
const seen = Math.max(0, Math.min(br.right, cr.right) - Math.max(br.left, cr.left));
return {
ratio: Number((seen / br.width).toFixed(3)),
scrollLeft: Number(box.scrollLeft.toFixed(1)),
maxScrollLeft: Number((box.scrollWidth - box.clientWidth).toFixed(1)),
rowTop: Number(rr.top.toFixed(1)),
pageScrollTop: Number(document.scrollingElement!.scrollTop.toFixed(1)),
deleteFocused: document.activeElement === btn,
right: Number(br.right.toFixed(1)),
};
});
}
/** なぞった位置を 0 に戻す(前の幅で引き込んだ分を持ち越さないため)。 */
async function resetScroll(page: Page): Promise<void> {
await page.evaluate(() => {
(document.querySelector('[data-testid="space-header-meta"]') as HTMLElement).scrollLeft = 0;
});
}
/**
* 手で目一杯なぞったときに、削除ボタンがどこまで見えるかを測る。
*
* これが**利用者に到達できるいちばん良い位置**であり、引き込みの合否はここを基準に
* 判定する。絶対値 1.00 は基準にできない(冒頭の説明を参照)。
*/
async function measureManualBest(page: Page): Promise<Measured> {
await page.evaluate(() => {
const box = document.querySelector('[data-testid="space-header-meta"]') as HTMLElement;
box.scrollLeft = box.scrollWidth;
});
return measure(page);
}
/**
* 本物の Tab で削除ボタンまで進む。
*
* **`locator.focus()` を使わない。**あれは押鍵を発生させないので、
* 「キーボードのときだけ引き込む」判定を通り抜けてしまい、判定が壊れていても
* 緑のままになる。
*/
async function tabToDeleteButton(page: Page): Promise<number> {
for (let i = 1; i <= 40; i++) {
await page.keyboard.press('Tab');
const onDelete = await page.evaluate(
() => document.activeElement?.getAttribute('data-testid') === 'space-delete',
);
if (onDelete) return i;
}
throw new Error('Tab を 40 回押しても削除ボタンに届かなかった');
}
/** 帯の中の、押しても画面が変わらないところへ焦点を戻す(Tab の出発点)。 */
async function resetFocusToTabStrip(page: Page): Promise<void> {
await page.getByTestId('space-tab-chat').click();
}
const WIDTHS = [429, 430, 435, 439, 445];
/**
* 測り方の丸め(`toFixed(3)`)とサブピクセルのぶんの遊び。
* 引き込みが手なぞりと同じ位置に着いても、割合が 0.001 単位でずれることがある。
*/
const RATIO_EPSILON = 0.01;
/**
* 削除ボタンが実際にはみ出す幅を 1 つ選ぶ。
*
* **430 を決め打ちにしない。**はみ出し始める幅は文字の計量で機械ごとに動くので、
* 決め打ちにすると別の機械では「はみ出していないところ」を測って空振りする。
*/
async function findOverflowWidth(page: Page): Promise<number> {
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const m = await measure(page);
if (m.ratio < 1) return width;
}
throw new Error('どの幅でも削除ボタンがはみ出さない(この網の前提が崩れている)');
}
test.describe('ワークスペースの帯: キーボードの焦点は画面外の操作子を引き込む', () => {
test('Tab で削除ボタンへ移ると、手でなぞれるところまで引き込む(縦は動かない)', async ({ page }) => {
await createAndOpenCaseSpace(page);
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const before = await measure(page);
// 手で目一杯なぞった結果を先に測る。これが合否の基準。
const manual = await measureManualBest(page);
await resetScroll(page);
await tabToDeleteButton(page);
const after = await measure(page);
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(
`[#1379] ${width}px 焦点前 割合=${before.ratio} なぞり=${before.scrollLeft} 右端=${before.right}` +
` / 手なぞり最大 割合=${manual.ratio} なぞり=${manual.scrollLeft}(上限=${manual.maxScrollLeft})` +
` / 焦点後 割合=${after.ratio} なぞり=${after.scrollLeft} 右端=${after.right}`,
);
expect(after.deleteFocused, `${width}px で削除ボタンに焦点が来ていない`).toBe(true);
expect(
after.ratio,
`${width}px で、手でなぞれるところまで引き込めていない(手なぞり最大 ${manual.ratio})`,
).toBeGreaterThanOrEqual(Math.min(1, manual.ratio) - RATIO_EPSILON);
// 縦は 1 ピクセルも動かさない。
expect(after.rowTop, `${width}px で帯が縦に動いた`).toBe(before.rowTop);
expect(after.pageScrollTop, `${width}px でページが縦に飛んだ`).toBe(before.pageScrollTop);
// はみ出していた幅では、実際に引き込んでいること(網が空振りしていない証拠)。
if (before.ratio < 1) {
expect(after.scrollLeft, `${width}px ではみ出していたのに箱が動いていない`).toBeGreaterThan(
before.scrollLeft,
);
}
}
});
test('はみ出す幅が実在する(この網が空振りしていないこと)', async ({ page }) => {
// **この網は引き込みの仕組みを判定していない。**焦点を 1 度も動かさないので、
// 引き込みを丸ごと外しても緑のまま。ここで確かめるのは上の網の前提——
// 「焦点を当てなければ実際にはみ出す幅がある」——だけ。
await createAndOpenCaseSpace(page);
const overflowed: number[] = [];
for (const width of WIDTHS) {
await resizeAndSettle(page, width);
await resetFocusToTabStrip(page);
await resetScroll(page);
const before = await measure(page);
if (before.ratio < 1) overflowed.push(width);
}
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(`[#1379] 焦点なしではみ出す幅: ${overflowed.join(', ') || 'なし'}`);
expect(overflowed.length, 'どの幅でもはみ出さないなら、上の網は何も守っていない').toBeGreaterThan(0);
});
test('同じ場面で、キーボードなら動きマウスなら動かない', async ({ page }) => {
await createAndOpenCaseSpace(page);
// はみ出す幅を実測で選ぶ(決め打ちにすると別の機械で空振りする)。
const width = await findOverflowWidth(page);
// --- キーボードの側。ここが無いと「いつでも動かない」と区別が付かない ---
// マウス側だけを見る網は、引き込みを丸ごと外しても緑になる(動かないのが
// 当たり前だから)。同じ幅・同じボタンで「押鍵なら動く」を対にして測る。
await resetFocusToTabStrip(page);
await resetScroll(page);
const beforeKey = await measure(page);
await tabToDeleteButton(page);
const afterKey = await measure(page);
// --- マウスの側 ---
await resetFocusToTabStrip(page);
await resetScroll(page);
const beforeMouse = await measure(page);
// **`locator.click()` を使わない。**Playwright は押す前に要素を画面内へ
// 引き込むので、こちらの処理が何もしなくても箱が動いてしまう。見えている
// ところを座標で押す(=利用者が実際にできること)。
const spot = await page.evaluate(() => {
const btn = document.querySelector('[data-testid="space-delete"]')!.getBoundingClientRect();
const box = document
.querySelector('[data-testid="space-header-meta"]')!
.getBoundingClientRect();
const right = Math.min(btn.right, box.right);
return { x: (btn.left + right) / 2, y: btn.top + btn.height / 2 };
});
await page.mouse.move(spot.x, spot.y);
await page.mouse.down();
const onPress = await measure(page);
await page.mouse.up();
// eslint-disable-next-line no-console -- 実測値を報告に残す
console.log(
`[#1379] ${width}px キーボード なぞり=${beforeKey.scrollLeft}→${afterKey.scrollLeft}` +
` / マウス なぞり=${beforeMouse.scrollLeft}→${onPress.scrollLeft} 焦点=${onPress.deleteFocused}`,
);
expect(afterKey.deleteFocused, 'Tab で削除ボタンに焦点が来ていない').toBe(true);
expect(
afterKey.scrollLeft,
'キーボードでも箱が動いていない(引き込みが効いていない)',
).toBeGreaterThan(beforeKey.scrollLeft);
expect(onPress.deleteFocused, 'マウスで押しても焦点が来ていない(この網は空振り)').toBe(true);
expect(onPress.scrollLeft, 'マウスで押した瞬間に箱が動いた').toBe(beforeMouse.scrollLeft);
// 押せていること(見えている端を押しただけで確認の段に進む)。
await expect(page.getByTestId('space-delete-confirm')).toBeVisible();
});
});
+466 -236
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -1,6 +1,5 @@
import { test, expect } from '@playwright/test';
import Database from 'better-sqlite3';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -8,9 +7,11 @@ import { fileURLToPath } from 'node:url';
// agent must call RequestTool). In this LLM-less E2E we seed the engine state
// directly into the throwaway DB: a job parked with wait_reason='tool_request'
// + a pending tool_request, then drive the inline approval card in the chat.
// The DB path is handed off by playwright.config.ts via ui/.e2e-db-path.
// The run-scoped DB path is inherited from playwright.config.ts via E2E_TMP.
const __dirname = dirname(fileURLToPath(import.meta.url));
const dbPath = readFileSync(resolve(__dirname, '..', '.e2e-db-path'), 'utf-8').trim();
const e2eTmp = process.env.E2E_TMP;
if (!e2eTmp) throw new Error('E2E_TMP must be set by playwright.config.ts');
const dbPath = resolve(e2eTmp, 'e2e.db');
test('inline tool-approval: the card renders and Approve grants + resolves the request', async ({ page, request }) => {
// 1. Create a task (spawns a job).
+3 -1
View File
@@ -39,7 +39,9 @@
}
html { background: #ffffff; }
html[data-theme="dark"] { background: #18181c; }
/* 初回描画のちらつき止め。ui/src/index.css の --canvas と同じ値を持つ(#1416)。
この <style> は index.css の読み込み前に効くので、変数では書けない。 */
html[data-theme="dark"] { background: #1e1e23; }
body {
margin: 0;
+2679 -36
View File
File diff suppressed because it is too large Load Diff
+31 -6
View File
@@ -4,11 +4,16 @@
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "node ../scripts/validate-help-docs.mjs && vite build",
"dev": "npm --prefix .. run build:contract && vite",
"typecheck": "npm --prefix .. run build:contract && tsc -p tsconfig.typecheck.json",
"build": "npm --prefix .. run build:contract && node ../scripts/validate-help-docs.mjs && tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@aao/answer-worker-contract": "file:../packages/answer-worker-contract",
"@fontsource/ibm-plex-mono": "5.3.0",
"@fontsource/ibm-plex-sans-jp": "5.3.0",
"@fontsource/jetbrains-mono": "5.3.0",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
@@ -16,16 +21,34 @@
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@types/dompurify": "^3.0.5",
"@univerjs/core": "0.25.1",
"@univerjs/design": "0.25.1",
"@univerjs/docs": "0.25.1",
"@univerjs/docs-ui": "0.25.1",
"@univerjs/engine-formula": "0.25.1",
"@univerjs/engine-render": "0.25.1",
"@univerjs/network": "0.25.1",
"@univerjs/rpc": "0.25.1",
"@univerjs/sheets": "0.25.1",
"@univerjs/sheets-formula": "0.25.1",
"@univerjs/sheets-formula-ui": "0.25.1",
"@univerjs/sheets-numfmt": "0.25.1",
"@univerjs/sheets-numfmt-ui": "0.25.1",
"@univerjs/sheets-ui": "0.25.1",
"@univerjs/themes": "0.25.1",
"@univerjs/ui": "0.25.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"autoprefixer": "^10.4.27",
"dompurify": "^3.4.8",
"dompurify": "^3.4.13",
"highlight.js": "^11.11.1",
"i18next": "^26.3.1",
"i18next-browser-languagedetector": "^8.2.1",
"leaflet": "1.9.4",
"marked": "^17.0.4",
"mermaid": "^11.15.0",
"postcss": "^8.5.15",
"mermaid": "^11.16.1",
"monaco-editor": "0.55.1",
"postcss": "^8.5.26",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.8",
@@ -38,15 +61,17 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^20.11.30",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^6.0.2",
"dom-accessibility-api": "^0.5.16",
"typescript": "^5.4.3",
"vite": "^8.0.16"
},
"overrides": {
"monaco-editor": {
"dompurify": "3.4.8"
"dompurify": "3.4.13"
}
}
}
+76 -18
View File
@@ -1,9 +1,10 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { mkdirSync } from 'node:fs';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { createOwnedRunDir } from '../scripts/e2e-run-dir.mjs';
// __dirname under ESM.
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -11,27 +12,72 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '..');
// Separate port from the auth-OFF harness (8788) so both configs can coexist.
const PORT = Number(process.env.E2E_AUTH_PORT ?? 8790);
if (!process.env.E2E_AUTH_PORT) process.env.E2E_AUTH_PORT = execFileSync(process.execPath, ['-e', "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})"], { encoding: 'utf8' }).trim();
const PORT = Number(process.env.E2E_AUTH_PORT);
const BASE_URL = `http://127.0.0.1:${PORT}`;
// Isolated throwaway data + workspace dirs — never touches real ./data/maestro.db.
//
// DETERMINISTIC path (NOT mkdtemp): Playwright imports this config in the main
// runner process AND re-imports it in each spec worker process. A random
// mkdtemp would yield a DIFFERENT DB_PATH per import — the webServer (started
// from the main-process value) and the spec's seeding code would then open
// DIFFERENT databases, so seeded members would never appear. A fixed path makes
// every import agree on one DB. The spec computes the SAME path independently
// (see DB_PATH in the spec) and seeds into it. We do NOT delete it at import
// time (a worker import would nuke the live DB); the server rebuilds its schema
// on boot and seeding is idempotent, so a stale file from a prior run is benign.
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
mkdirSync(e2eTmp, { recursive: true });
// Mint the run directory once and propagate it through the environment. Config
// worker imports inherit the same value, while concurrent runs/worktrees never
// share or delete one another's database.
// E2E_RUN_OWNED が立っているのに実行用ディレクトリが無い状態は、手で
// 環境変数を export したときにしか作れない。ここで先に落とす: 一時
// ディレクトリを 1 つでも作った後に投げると、config が完走せず
// globalTeardown も走らないため、作った分が掃除されず残る。
if (process.env.E2E_RUN_OWNED === '1' && !process.env.E2E_RUN_TMP) {
throw new Error('E2E_RUN_OWNED=1 but E2E_RUN_TMP is not set');
}
const cache = join(repoRoot, 'node_modules', '.cache');
mkdirSync(cache, { recursive: true });
if (!process.env.E2E_AUTH_TMP) {
const ownedData = createOwnedRunDir(cache, 'maestro-e2e-auth-data-');
process.env.E2E_AUTH_TMP = ownedData.directory;
process.env.E2E_DATA_TOKEN = ownedData.token;
}
const e2eTmp = process.env.E2E_AUTH_TMP;
if (process.env.E2E_RUN_OWNED !== '1') {
const ownedRun = createOwnedRunDir(cache, 'maestro-e2e-auth-run-');
process.env.E2E_RUN_TMP = ownedRun.directory;
process.env.E2E_RUN_TOKEN = ownedRun.token;
process.env.E2E_RUN_OWNED = '1';
}
process.env.E2E_RUN_PREFIX = 'maestro-e2e-auth-run-';
// 冒頭のガードが「OWNED=1 なのに未設定」を弾き、上の分岐が「未 OWNED」を
// 必ず設定する。したがってここでは必ず値がある(TS はこの 2 段を追えない)。
const e2eRunTmp = process.env.E2E_RUN_TMP!;
process.env.E2E_CACHE_ROOT = cache;
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
// Absolute path to the local-auth config YAML (enables POST /auth/local login).
const AAO_CONFIG = resolve(repoRoot, 'ui/e2e-auth/config.e2e-auth.yaml');
// The checked-in local-auth config YAML (enables POST /auth/local login).
const AUTH_CONFIG_SOURCE = resolve(repoRoot, 'ui/e2e-auth/config.e2e-auth.yaml');
// Assemble a THROWAWAY config for the webServer: the checked-in local-auth YAML
// plus one harness-only section appended at run time. Same shape as the auth-OFF
// harness (ui/playwright.config.ts, #1394); the extra section is deliberately NOT
// written into ui/e2e-auth/config.e2e-auth.yaml, because a checked-in config that
// says "outbound network is allowed" reads like part of the production setup.
//
// What gets appended and why (#1399): WITHOUT `safety.bash_allow_network: true`
// the webServer refuses to boot on any host where bwrap cannot create a user
// namespace. `safety.bash_sandbox` defaults to `auto`, and worker-bootstrap.ts
// (#1235) treats "network blocking is configured but cannot take effect" as a
// startup error rather than a silent downgrade. The dev container hits exactly
// that (the bwrap binary is present, `--unshare-all` is denied), and whether a CI
// runner does depends on its container settings — pinning this makes the boot
// outcome the same everywhere instead of a property of the host. Nothing is
// actually opened up: these specs never reach an LLM, so no agent Bash command
// ever runs.
//
// The write is unconditional on every config evaluation (main process + worker).
// That is idempotent: e2eTmp is stable across evaluations in this process and is
// inherited by the webServer subprocess via the environment.
const AAO_CONFIG = join(e2eTmp, 'e2e-auth-config.yaml');
writeFileSync(
AAO_CONFIG,
`${readFileSync(AUTH_CONFIG_SOURCE, 'utf8')}\nsafety:\n bash_allow_network: true\n`,
);
export default defineConfig({
testDir: resolve(__dirname, 'e2e-auth'),
@@ -39,14 +85,25 @@ export default defineConfig({
// Build+boot can be slow; roomy per-test budget.
timeout: 60_000,
expect: { timeout: 15_000 },
// 走り全体の上限。1 件 60 秒 × 21 件が全部詰まると、サーバー起動の 120 秒と
// 合わせて CI のジョブ上限(30 分・ci.yml の timeout-minutes)を食い破り、
// ログが途中で切れた赤になって原因が読めなくなる。実測は 21 件で 58.6 秒
// (CI run 3212)なので 10 分は十分な余裕がありつつ、暴走はここで止まる。
globalTimeout: 600_000,
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
globalTeardown: resolve(__dirname, 'e2e-global-teardown.ts'),
use: {
baseURL: BASE_URL,
headless: true,
// 表示言語を日本語に固定する。ここが抜けていると UI は英語で描画され、
// e2e-auth の spec が持つ日本語の文字列('追加'・'リンクが無効です' 等)は
// 一つも当たらない。認証なし側(ui/playwright.config.ts)は最初からこれを
// 置いていて、こちら側だけ抜けていた(#1399)。
locale: 'ja-JP',
// NOTE: the wide viewport that keeps the desktop nav (data-testid="nav-*")
// out of the hamburger drawer is set on the chromium project below, AFTER
// the Desktop Chrome device spread (which would otherwise pin 1280×720).
@@ -72,9 +129,9 @@ export default defineConfig({
// Boot the real orchestrator (worker mode) with LOCAL AUTH ON (AAO_CONFIG
// points at the local-auth YAML). The bootstrap admin is seeded at startup;
// member users are seeded by the spec's beforeAll into the same temp DB.
// dist/ must exist (npm run build:all).
// The webServer command builds both dist trees before starting Node.
webServer: {
command: 'node dist/main.js',
command: 'node scripts/e2e-web-server.mjs',
cwd: repoRoot,
url: `${BASE_URL}/ui`,
reuseExistingServer: false,
@@ -84,6 +141,7 @@ export default defineConfig({
env: {
...process.env,
PORT: String(PORT),
E2E_RUN_TMP: e2eRunTmp,
DB_PATH,
WORKTREE_DIR,
AAO_CONFIG,
+48 -11
View File
@@ -1,9 +1,10 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { createOwnedRunDir } from '../scripts/e2e-run-dir.mjs';
// __dirname under ESM.
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -11,7 +12,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '..');
// Dedicated E2E port — avoids clashing with a dev server on 9876.
const PORT = Number(process.env.E2E_PORT ?? 8788);
if (!process.env.E2E_PORT) process.env.E2E_PORT = execFileSync(process.execPath, ['-e', "const s=require('net').createServer();s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close()})"], { encoding: 'utf8' }).trim();
const PORT = Number(process.env.E2E_PORT);
const BASE_URL = `http://127.0.0.1:${PORT}`;
// Isolated, throwaway data + workspace dirs so the E2E never touches real data
@@ -19,23 +21,43 @@ const BASE_URL = `http://127.0.0.1:${PORT}`;
// space is auto-created clean on first /api/local/spaces hit.
// The temp dir MUST be stable across the multiple times Playwright evaluates
// this config (main process + worker), otherwise mkdtempSync would mint a new
// dir per evaluation and the .e2e-db-path handoff file could point at a
// different DB than the one the webServer actually booted with (→ specs open an
// empty DB and fail with "no such table: jobs"). Self-seed it into the
// dir per evaluation and specs could open a different DB from the webServer
// (→ "no such table: jobs"). Self-seed it into the
// environment on the FIRST evaluation: subsequent evals in this process reuse
// it, and the webServer subprocess + worker processes inherit it via env. No
// caller setup required (plain `npm run test:e2e` works); an explicitly-set
// E2E_TMP is still honored.
// E2E_RUN_OWNED が立っているのに実行用ディレクトリが無い状態は、手で
// 環境変数を export したときにしか作れない。ここで先に落とす: 一時
// ディレクトリを 1 つでも作った後に投げると、config が完走せず
// globalTeardown も走らないため、作った分が掃除されず残る。
if (process.env.E2E_RUN_OWNED === '1' && !process.env.E2E_RUN_TMP) {
throw new Error('E2E_RUN_OWNED=1 but E2E_RUN_TMP is not set');
}
const cache = join(repoRoot, 'node_modules', '.cache');
mkdirSync(cache, { recursive: true });
if (!process.env.E2E_TMP) {
process.env.E2E_TMP = mkdtempSync(join(tmpdir(), 'maestro-e2e-'));
const ownedData = createOwnedRunDir(cache, 'maestro-e2e-data-');
process.env.E2E_TMP = ownedData.directory;
process.env.E2E_DATA_TOKEN = ownedData.token;
}
const e2eTmp = process.env.E2E_TMP;
if (process.env.E2E_RUN_OWNED !== '1') {
const ownedRun = createOwnedRunDir(cache, 'maestro-e2e-run-');
process.env.E2E_RUN_TMP = ownedRun.directory;
process.env.E2E_RUN_TOKEN = ownedRun.token;
process.env.E2E_RUN_OWNED = '1';
}
process.env.E2E_RUN_PREFIX = 'maestro-e2e-run-';
// 冒頭のガードが「OWNED=1 なのに未設定」を弾き、上の分岐が「未 OWNED」を
// 必ず設定する。したがってここでは必ず値がある(TS はこの 2 段を追えない)。
const e2eRunTmp = process.env.E2E_RUN_TMP!;
process.env.E2E_CACHE_ROOT = cache;
const DB_PATH = join(e2eTmp, 'e2e.db');
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
// Expose the throwaway DB path to specs that need to seed engine state which
// can't be produced without a live LLM (e.g. a job parked for tool approval).
// Written to a gitignored file next to this config so specs can read it.
writeFileSync(join(__dirname, '.e2e-db-path'), DB_PATH);
// Dedicated, deterministic config for the E2E webServer, handed over via
// AAO_CONFIG. Two reasons:
@@ -51,8 +73,17 @@ writeFileSync(join(__dirname, '.e2e-db-path'), DB_PATH);
// scraped the brief loading-state render and passed by luck; under full-
// suite load that window closed and both SSH specs failed. Enabling SSH
// (needs the MCP_ENCRYPTION_KEY set below) makes the panel deterministic.
// 3) Bash outbound network allowed — WITHOUT this the webServer refuses to boot
// on any host where bwrap cannot create a user namespace. safety.bash_sandbox
// defaults to `auto`, and worker-bootstrap.ts (#1235) treats "network blocking
// is configured but cannot take effect" as a startup error rather than a
// silent downgrade. The dev container hits exactly that (the bwrap binary is
// present, `--unshare-all` is denied), and whether a CI runner does depends on
// its container settings — pinning this makes the boot outcome the same
// everywhere instead of a property of the host. Nothing is actually opened up:
// these specs never reach an LLM, so no agent Bash command ever runs.
const E2E_CONFIG = join(e2eTmp, 'e2e-config.yaml');
writeFileSync(E2E_CONFIG, 'ssh:\n enabled: true\n');
writeFileSync(E2E_CONFIG, 'ssh:\n enabled: true\nsafety:\n bash_allow_network: true\n');
export default defineConfig({
testDir: resolve(__dirname, 'e2e'),
@@ -63,6 +94,7 @@ export default defineConfig({
workers: 1,
retries: 0,
reporter: [['list']],
globalTeardown: resolve(__dirname, 'e2e-global-teardown.ts'),
use: {
baseURL: BASE_URL,
@@ -83,11 +115,15 @@ export default defineConfig({
},
],
// Build before booting so a plain `npm run test:e2e` never serves a stale or
// half-cleaned ui/dist tree. Previously the server could observe ui/dist/
// after Vite emptied it but before index.html was emitted, causing sendFile
// ENOENT failures when E2E and a build ran near each other.
// Boot the real orchestrator (worker mode) with auth OFF (the pinned
// E2E_CONFIG has no auth section => defaults => synthetic 'local' user).
// dist/ must exist (npm run build:all).
// The webServer command builds both dist trees before starting Node.
webServer: {
command: 'node dist/main.js',
command: 'node scripts/e2e-web-server.mjs',
cwd: repoRoot,
url: `${BASE_URL}/ui`,
reuseExistingServer: false,
@@ -97,6 +133,7 @@ export default defineConfig({
env: {
...process.env,
PORT: String(PORT),
E2E_RUN_TMP: e2eRunTmp,
DB_PATH,
WORKTREE_DIR,
// Pin the config to the throwaway E2E file so the run never inherits a
+16
View File
@@ -0,0 +1,16 @@
Locally bundled UI assets
Fontsource 5.3.0 (SIL Open Font License 1.1):
ibm-plex-sans-jp-OFL.txt
ibm-plex-mono-OFL.txt
jetbrains-mono-OFL.txt
Leaflet 1.9.4 (BSD 2-Clause):
leaflet.txt
Monaco Editor 0.55.1 (MIT and third-party notices):
monaco-editor.txt
monaco-editor-ThirdPartyNotices.txt
These files contain the upstream notices for the assets added in this change.
They are not a complete license inventory of every MAESTRO dependency.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2018 IBM Corp. All rights reserved. IBMPlexSansJP-ExtraLight.ttf: Copyright 2018 IBM Corp. All rights reserved. IBMPlexSansJP-Light.ttf: Copyright 2018 IBM Corp. All rights reserved. IBMPlexSansJP-Regular.ttf: Copyright 2018 IBM Corp. All rights reserved. IBMPlexSansJP-Medium.ttf: Copyright 2018 IBM Corp. All rights reserved. IBMPlexSansJP-SemiBold.ttf: Copyright 2018 IBM Corp. All rights reserved. IBMPlexSansJP-Bold.ttf: Copyright 2018 IBM Corp. All rights reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+26
View File
@@ -0,0 +1,26 @@
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 - present Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+56
View File
@@ -0,0 +1,56 @@
/* Stateful box-filter resampling: phase and integration survive render quanta. */
class TranscriptionPCM extends AudioWorkletProcessor {
constructor() {
super();
this.ratio = sampleRate / 16000;
this.weight = 0;
this.sum = 0;
this.samples = [];
this.stopped = false;
this.port.onmessage = ({ data }) => {
if (data.type === 'flush') {
this.stopped = true;
if (this.weight > 0) this.samples.push(this.sum / this.weight);
this.emit();
this.port.postMessage({ type: 'flushed' });
}
};
}
emit() {
if (!this.samples.length) return;
const buffer = new ArrayBuffer(this.samples.length * 2);
const view = new DataView(buffer);
let energy = 0;
this.samples.forEach((value, index) => {
const sample = Math.max(-1, Math.min(1, value));
view.setInt16(index * 2, Math.round(sample * (sample < 0 ? 32768 : 32767)), true);
energy += sample * sample;
});
this.port.postMessage({ type: 'pcm', buffer, level: Math.sqrt(energy / this.samples.length) }, [buffer]);
this.samples = [];
}
process(inputs) {
if (this.stopped) return false;
const channels = inputs[0];
if (!channels?.length) return true;
for (let i = 0; i < channels[0].length; i++) {
let value = 0;
for (const channel of channels) value += channel[i] / channels.length;
let remaining = 1;
while (remaining > 1e-9) {
const take = Math.min(remaining, this.ratio - this.weight);
this.sum += value * take;
this.weight += take;
remaining -= take;
if (this.weight >= this.ratio - 1e-9) {
this.samples.push(this.sum / this.ratio);
this.sum = 0;
this.weight = 0;
if (this.samples.length >= 2400) this.emit();
}
}
}
return true;
}
}
registerProcessor('transcription-pcm', TranscriptionPCM);
+204 -143
View File
@@ -1,89 +1,75 @@
import { UiInspectorProvider } from './components/inspector/UiInspector';
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSetupState } from './hooks/useSetupState';
import { useAuthState, type AuthUser } from './hooks/useAuthState';
import { SetupWizard } from './components/setup/SetupWizard';
import { createLocalTask, fetchLocalTask, type CreateLocalTaskInput, type Visibility } from './api';
import { claimSpaceCalendarReminders } from './api/calendar';
import { createTaskAndOpen } from './lib/createTaskAndOpen';
import { createLocalTask, fetchLocalTask, fetchNotificationCapabilities, type CreateLocalTaskInput } from './api';
import { useUrlState } from './hooks/useUrlState';
import { useToast } from './hooks/useToast';
import { ToastHost } from './components/notifications/ToastHost';
import { ToastPet } from './components/notifications/ToastPet';
import { useLocalTaskList } from './hooks/useTaskList';
import { useBranding } from './hooks/useBranding';
import { useLocalStorageState } from './hooks/useLocalStorageState';
import { useTaskNotifications } from './hooks/useTaskNotifications';
import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications';
import { normalizeLegacyTasksUrl, parseLegacyTasksUrl, buildUiUrlStateSearch, type PageId } from './lib/urlState';
import { normalizeLegacyTasksUrl, parseLegacyTasksUrl, parseNotificationUiHref, buildUiUrlStateSearch, type PageId } from './lib/urlState';
import { confirmDiscardUnsaved } from './lib/unsavedGuard';
import { TopBar } from './components/layout/TopBar';
import { InsecureExposureBanner } from './components/layout/InsecureExposureBanner';
import { SetupGateScreen } from './components/setup/SetupGateScreen';
import { NavDrawer } from './components/layout/NavDrawer';
import { NavRail } from './components/layout/NavRail';
import { WorkerStatusWindowProvider } from './components/layout/WorkerStatusWindow';
import { ShellHeader } from './components/layout/ShellHeader';
import { AppShell } from './components/layout/AppShell';
import { useShellLayout } from './hooks/useShellLayout';
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
import { visibleNavItemsFor } from './components/layout/TopBar';
import { useNavRailCollapsed } from './hooks/useNavRailCollapsed';
import { CreateTaskDialog } from './components/create/CreateTaskDialog';
import { ChatTranscriptionProvider, TranscriptionActivityBanner } from './components/transcription/ChatTranscriptionProvider';
import { SettingsPage } from './pages/SettingsPage';
import { PiecesPage } from './pages/PiecesPage';
import { SchedulesPage } from './pages/SchedulesPage';
import { UsersPage } from './pages/UsersPage';
import { AdminCaptchaPage } from './pages/AdminCaptchaPage';
import { SharedView } from './pages/SharedView';
import { RetiredSharedView } from './pages/RetiredSharedView';
import { SharedAppView } from './pages/SharedAppView';
import { JoinSpace } from './components/spaces/JoinSpace';
import { matchPublicRoute } from './lib/publicRoute';
import { UsagePage } from './components/usage/UsagePage';
import { HelpPage } from './pages/HelpPage';
import { NotificationCenterPage } from './pages/NotificationCenterPage';
import { ActivityPage } from './pages/ActivityPage';
import { SpaceRailUrlContext } from './components/spaces/SpaceRailUrlContext';
import { SpacesPage } from './components/spaces/SpacesPage';
import { CrossSpaceCalendar } from './components/spaces/CrossSpaceCalendar';
import { useSpaces } from './hooks/useSpaces';
import { findPersonalSpaceId } from './lib/personalSpace';
import { CommandPalette } from './components/command/CommandPalette';
import { shouldOpenForKeyEvent, type CommandContext } from './lib/command-palette';
import { commandPaletteKeyContext, shouldOpenForKeyEvent, type CommandContext } from './lib/command-palette';
import { setThemePref } from './lib/theme';
export interface AuthUser {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
role: 'admin' | 'user';
orgIds?: string[];
defaultVisibility?: Visibility;
defaultVisibilityOrgId?: string | null;
/** True for accounts with a local (email+password) credential; false/undefined for OAuth-only accounts. */
hasLocalCredential?: boolean;
}
const EVENT_TO_VARIANT: Record<'running' | 'succeeded' | 'failed' | 'waiting_human', 'info' | 'success' | 'error' | 'warning'> = {
running: 'info',
succeeded: 'success',
failed: 'error',
waiting_human: 'warning',
};
type AuthMode =
| { mode: 'disabled' }
| { mode: 'loading' }
| { mode: 'authenticated'; user: AuthUser }
| { mode: 'unauthenticated' };
export function useAuthState(): AuthMode {
const { data, error, isLoading } = useQuery({
queryKey: ['auth', 'me'],
queryFn: async () => {
const res = await fetch('/api/auth/me');
if (res.status === 404) return { mode: 'disabled' as const };
if (res.status === 401) return { mode: 'unauthenticated' as const };
if (!res.ok) throw new Error('Auth check failed');
const user = await res.json();
return { mode: 'authenticated' as const, user };
},
retry: false,
staleTime: 5 * 60 * 1000,
});
if (isLoading) return { mode: 'loading' };
if (error || !data) return { mode: 'disabled' };
return data;
}
// ログイン状態の hook と型は ./hooks/useAuthState に移した(#1399)。
// 既存の `import { useAuthState } from '../../App'` を壊さないよう再輸出する。
export { useAuthState } from './hooks/useAuthState';
export type { AuthUser, AuthMode } from './hooks/useAuthState';
export function App() {
// 認証ゲートの外側で扱う公開ルートをパスマッチ(純関数)で判定する。
const publicRoute = matchPublicRoute(window.location.pathname);
if (publicRoute) {
// 共有タスク: /ui/shared/:token — 認証不要
if (publicRoute.kind === 'shared') return <SharedView token={publicRoute.token} />;
// 廃止済みタスク共有リンク: /ui/shared/:token — 認証不要の静的告知
if (publicRoute.kind === 'shared') return <RetiredSharedView />;
// 公開アプリ: /ui/app/:token — 認証不要・read-only
if (publicRoute.kind === 'app') return <SharedAppView token={publicRoute.token} />;
// 招待リンク: /ui/invite/:token — 認証は必要だが、未ログイン時は JoinSpace 自身が
@@ -123,6 +109,7 @@ function AuthenticatedApp() {
// setup wizard instead of the app. Fail-open — if the status check errors, fall
// through to the app rather than blocking it.
function SetupGate({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabled: boolean; user: AuthUser | null }) {
const { t } = useTranslation('setup');
const { data: setup, isLoading } = useSetupState();
const queryClient = useQueryClient();
@@ -136,28 +123,34 @@ function SetupGate({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabl
if (setup?.needsSetup) {
const canConfigure = !setup.authActive || isAdmin;
// An unconfigured instance can still be open on the network — the worst
// combination — so these screens carry the warning bar too (#1026 review).
if (canConfigure) {
return (
<SetupWizard
status={setup}
onComplete={() => {
queryClient.invalidateQueries({ queryKey: ['setup'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['config'] });
queryClient.invalidateQueries({ queryKey: ['workers'] });
}}
/>
<SetupGateScreen exposed={setup.unauthenticatedExposure}>
<SetupWizard
status={setup}
onComplete={() => {
queryClient.invalidateQueries({ queryKey: ['setup'] });
queryClient.invalidateQueries({ queryKey: ['auth'] });
queryClient.invalidateQueries({ queryKey: ['config'] });
queryClient.invalidateQueries({ queryKey: ['workers'] });
}}
/>
</SetupGateScreen>
);
}
return (
<div className="h-dvh flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md text-center">
<h1 className="text-lg font-semibold text-slate-800 mb-2">Setup needed</h1>
<p className="text-sm text-slate-600">
This server has no language model configured yet. Ask an administrator to finish setup from Settings.
</p>
<SetupGateScreen exposed={setup.unauthenticatedExposure}>
<div className="h-full flex items-center justify-center bg-slate-50 px-4">
<div className="max-w-md text-center">
<h1 className="text-lg font-semibold text-slate-800 mb-2">{t('gate.title')}</h1>
<p className="text-sm text-slate-600">
{t('gate.body')}
</p>
</div>
</div>
</div>
</SetupGateScreen>
);
}
@@ -166,6 +159,7 @@ function SetupGate({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabl
function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabled: boolean; user: AuthUser | null }) {
const { t } = useTranslation('layout');
const { data: setupStatus } = useSetupState();
const { t: tList } = useTranslation('list');
const queryClient = useQueryClient();
// Apply branding (document.title + --brand-primary CSS var)
@@ -177,43 +171,15 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
urlStateRef.current = urlState;
// 認証なしで users ページにアクセスした場合は spaces にフォールバック
const page = (urlState.page === 'users' && !authEnabled) ? 'spaces' : urlState.page;
const [legacyTranscriptionNotice, setLegacyTranscriptionNotice] = useState(() => new URLSearchParams(window.location.search).get('page') === 'transcription');
// 個人ワークスペースの解決。spaces 一覧から「自分が所有する kind === 'personal'」
// を拾う。バックエンドは既に個人スペースを所有者のみ可視に絞るが、二重の防御と
// して owner 一致も明示チェックする(admin が他ユーザーの個人スペースを誤って
// 自分のものとして拾わないように)。認証なしモードでは synthetic user が唯一の
// 個人スペースを所有するので user=null でも従来通り解決する。
// 個人ワークスペースの解決(判定は lib/personalSpace.ts に集約。SpaceDetail も
// 同じ関数を使う——所属が空のタスクをどこで開くかは 1 か所で決める)。
// 一覧が空/ローディング中なら personalSpaceId は null(graceful skeleton)。
const { data: spaces } = useSpaces();
const { toasts, showToast, dismissToast } = useToast();
const [settingsSpaceId, setSettingsSpaceId] = useState<string | null>(null);
const personalSpaceId =
spaces?.find(
s => s.kind === 'personal' && (user == null || s.ownerId === user.id),
)?.id ?? null;
useEffect(() => {
if (!spaces?.length) return;
let cancelled = false;
const poll = async () => {
const events = await Promise.all(spaces.map(space => claimSpaceCalendarReminders(space.id).then(items => ({ space, items })).catch(() => null)));
if (cancelled) return;
for (const result of events) {
if (!result) continue;
for (const event of result.items) {
showToast(event.time ? `${event.date} ${event.time}` : event.date, 'info', {
id: `calendar-event-${event.id}`,
title: `予定: ${event.title}`,
actionLabel: 'カレンダーを開く',
onAction: () => setUrlState(prev => ({ ...prev, page: 'calendar', spaceId: result.space.id, spaceTaskId: undefined })),
});
}
}
};
void poll();
const timer = window.setInterval(() => void poll(), 30_000);
return () => { cancelled = true; window.clearInterval(timer); };
}, [spaces, showToast, setUrlState]);
const personalSpaceId = findPersonalSpaceId(spaces, user);
// Legacy `?page=tasks` deep links (the Tasks tab was removed in M2) are
// normalized onto the workspace model once spaces have loaded. We wait for the
@@ -290,7 +256,13 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
const hamburgerRef = useRef<HTMLButtonElement>(null);
// compactMode is measured by TopBar (actual fit) and reported up here so the
// nav drawer / edge-swipe stay in sync with whether the hamburger is shown.
const [compactMode, setCompactMode] = useState(false);
const [topBarCompact, setTopBarCompact] = useState(false);
// #1360 段 A: 画面の骨組みの形(既定は従来の上の帯)。設定 → Preferences で切り替える。
const shellLayout = useShellLayout();
// 縦レールを畳むかどうか。境目(帯の切り替わる幅 + レールの幅)とその理由は
// lib/shellMetrics.ts の RAIL_COLLAPSE_WIDTH に書いてある(#1418)。
const railNarrow = useNavRailCollapsed();
const compactMode = shellLayout === 'rail' ? railNarrow : topBarCompact;
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const openNavDrawer = () => {
@@ -370,7 +342,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// Tracks the most recent open request so a slow single-task fetch can't
// override a newer navigation (user clicks task A then B while A resolves).
const openTaskSeq = useRef(0);
const handleOpenTaskInSpace = useCallback((id: number, spaceIdHint?: string | null) => {
const handleOpenTaskInSpace = useCallback((id: number, spaceIdHint?: string | null, targetTab?: 'transcription') => {
const seq = ++openTaskSeq.current;
// Select the chat immediately so the UI responds; only the WORKSPACE rail
// may need an async resolve below.
@@ -380,6 +352,8 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
page: 'spaces',
spaceId: spaceId ?? prev.spaceId,
spaceTaskId: id > 0 ? id : undefined,
spaceRailTab: targetTab,
...(targetTab ? { detailTab: targetTab, mobileTab: targetTab } : {}),
}));
// spaceIdHint lets callers that already know the owning workspace (e.g. a
@@ -428,16 +402,25 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
})();
}, [localTasksQuery.data, personalSpaceId, setUrlState, replaceUrlSearch]);
const handleNotificationHref = useCallback((href: string) => {
const target = parseNotificationUiHref(href);
if (!target || !confirmDiscardUnsaved()) return;
if (target.kind === 'task') {
handleOpenTaskInSpace(target.taskId, target.spaceId);
return;
}
setUrlState(prev => ({ ...prev, page: 'calendar', spaceId: target.spaceId, spaceTaskId: undefined }));
}, [handleOpenTaskInSpace, setUrlState]);
// ⌘K command palette
const [cmdkOpen, setCmdkOpen] = useState(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = document.activeElement as Element | null;
const inTerminal = !!el?.closest('.xterm');
// Monaco / contenteditable bind ⌘K/Ctrl-K as chord prefixes — don't steal.
const inEditor = !!el?.closest('.monaco-editor, [contenteditable="true"]');
if (shouldOpenForKeyEvent(e, { inTerminal, inEditor })) {
if (document.documentElement.dataset.uiInspectorActive === 'true') return;
// 端末とリッチエディタ(Monaco / contenteditable)だけは ⌘K/Ctrl-K を
// 自分で使うので横取りしない。判定は commandPaletteKeyContext に集約。
if (shouldOpenForKeyEvent(e, commandPaletteKeyContext(document.activeElement))) {
e.preventDefault();
setCmdkOpen((o) => !o);
}
@@ -446,6 +429,22 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
return () => window.removeEventListener('keydown', onKey, true);
}, []);
// ⌘K からワークスペースを開く。SpacesPage の onSelectSpace と同じ絞り込みリセットを
// 通す——検索で飛んだ先に、前のワークスペースの絞り込みが残らないように。
const handleOpenSpace = useCallback((id: string) => {
if (!confirmDiscardUnsaved()) return;
setUrlState(prev => ({
...prev,
page: 'spaces',
spaceId: id,
spaceTaskId: undefined,
spaceSearch: '',
spaceStatus: 'all',
spaceSort: 'updated',
spaceScope: 'mine',
}));
}, [setUrlState]);
const cmdkCtx: CommandContext = useMemo(() => ({
navigatePage: handleNavigatePage,
openTask: (id: number) => {
@@ -455,10 +454,12 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
if (!confirmDiscardUnsaved()) return;
handleOpenTaskInSpace(id);
},
openSpace: handleOpenSpace,
setTheme: setThemePref,
navItems: visibleNav.map((n) => ({ id: n.id, label: t(n.labelKey) })),
spaces: (spaces ?? []).map((sp) => ({ id: sp.id, title: sp.title })),
tasks: (localTasksQuery.data ?? []).map((t) => ({ id: t.id, title: t.title })),
}), [handleNavigatePage, handleOpenTaskInSpace, visibleNav, localTasksQuery.data]);
}), [handleNavigatePage, handleOpenTaskInSpace, handleOpenSpace, visibleNav, spaces, localTasksQuery.data]);
// ブラウザ通知設定 (localStorage) — 設定 UI は NotificationsForm が管理
const [notifyEnabled] = useLocalStorageState<boolean>('notify.enabled', true);
@@ -466,23 +467,36 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
'notify.events',
DEFAULT_NOTIFY_EVENTS,
);
const [notificationEventPipelineEnabled, setNotificationEventPipelineEnabled] = useState(false);
useEffect(() => {
let cancelled = false;
fetchNotificationCapabilities()
.then(({ eventPipelineEnabled }) => {
if (!cancelled) setNotificationEventPipelineEnabled(eventPipelineEnabled);
})
.catch(() => { /* Unknown capability retains legacy delivery. */ });
return () => { cancelled = true; };
}, []);
useTaskNotifications({
tasks: localTasksQuery.data,
currentUserId: user?.id ?? null,
enabled: notifyEnabled,
eventPipelineEnabled: notificationEventPipelineEnabled,
events: notifyEvents,
onNotificationClick: (taskId) => {
handleOpenTaskInSpace(taskId);
},
onInAppNotification: (notification) => {
const taskId = notification.data.taskId;
showToast(notification.body, notification.title.startsWith('❌') ? 'error' : 'info', {
const { taskId, workerId, lastBackendId, event } = notification.data;
showToast(notification.body, EVENT_TO_VARIANT[event] ?? 'info', {
id: notification.tag,
title: notification.title,
actionLabel: 'タスクを開く',
actionLabel: tList('toast.openTask'),
onAction: () => handleOpenTaskInSpace(taskId),
visual: notification.tag.endsWith('-succeeded') ? <ToastPet /> : undefined,
workerId,
lastBackendId,
});
},
});
@@ -513,48 +527,92 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
input: CreateLocalTaskInput,
attachments: Array<{ name: string; contentBase64: string }>,
) => {
try {
const created = await createLocalTask({ ...input, attachments });
setShowCreateDialog(false);
setCreateInitialPiece(null);
showToast(tList('toast.created', { id: created.task.id }));
queryClient.invalidateQueries({ queryKey: ['localTasks'] });
// Use the resolved workspace from the create response (the list query
// hasn't refetched yet) so a non-personal task opens in its own space.
handleOpenTaskInSpace(created.task.id, created.task.spaceId ?? input.spaceId ?? null);
} catch {
showToast(tList('toast.createFailed'), 'error');
}
// 一連(作成 → 閉じる → 通知 → 一覧更新 → 開く)は createTaskAndOpen にある。
// 失敗はここで握り潰さず CreateTaskDialog に投げ返す。飲み込むとダイアログは
// 成功扱いで閉じ、断られた理由も書きかけの本文も消える。
await createTaskAndOpen({
create: createLocalTask,
closeDialog: () => { setShowCreateDialog(false); setCreateInitialPiece(null); },
notifyCreated: (taskId) => showToast(tList('toast.created', { id: taskId })),
invalidateList: () => queryClient.invalidateQueries({ queryKey: ['localTasks'] }),
openTask: handleOpenTaskInSpace,
}, input, attachments);
}, [showToast, queryClient, handleOpenTaskInSpace, tList]);
return (
// ワーカー / GPU の浮く窓(#1448)は、入口(縦レール・引き出し・上の帯)が
// どこにあっても 1 つだけ出す。窓の描画はここが持つ——引き出しの中に置くと
// transform と inert に巻き込まれる。
<UiInspectorProvider key={user?.id ?? 'local'} isAdmin={isAdmin}>
<ChatTranscriptionProvider key={user?.id ?? 'local'} isAdmin={isAdmin} onOpenSettings={() => setUrlState(prev => ({ ...prev, page: 'settings', section: 'tools-media' }))} onOpenTask={id => handleOpenTaskInSpace(id, undefined, 'transcription')}>
<WorkerStatusWindowProvider>
<div className="h-dvh flex flex-col overflow-hidden bg-slate-50 text-slate-900" {...edgeSwipe}>
<TopBar
currentPage={page}
onNavigate={handleNavigatePage}
isAdmin={isAdmin}
authEnabled={authEnabled}
user={user}
appName={branding.appName}
logoUrl={branding.logoUrl}
onOpenDrawer={openNavDrawer}
hamburgerButtonRef={hamburgerRef}
navDrawerOpen={navDrawerOpen}
onOpenCommandK={() => setCmdkOpen(true)}
onCompactChange={setCompactMode}
/>
{/* Reads the same cached /api/setup/status the gate above already fetched. */}
<InsecureExposureBanner exposed={setupStatus?.unauthenticatedExposure} />
{/* #1360 段 A: 画面の骨組みは AppShell 1 か所で分岐する。既定(topbar)は
今までどおり TopBar とページ本体をそのまま出す。 */}
<TranscriptionActivityBanner />
<AppShell
topBar={
<TopBar
currentPage={page}
onNavigate={handleNavigatePage}
isAdmin={isAdmin}
authEnabled={authEnabled}
user={user}
appName={branding.appName}
logoUrl={branding.logoUrl}
onOpenDrawer={openNavDrawer}
hamburgerButtonRef={hamburgerRef}
navDrawerOpen={navDrawerOpen}
onOpenCommandK={() => setCmdkOpen(true)}
onCompactChange={setTopBarCompact}
onNavigateNotificationHref={handleNotificationHref}
onOpenNotificationCenter={(tab) => {
if (!confirmDiscardUnsaved()) return;
setUrlState(prev => ({ ...prev, page: 'notifications', notificationTab: tab }));
}}
/>
}
header={
<ShellHeader
appName={branding.appName}
logoUrl={branding.logoUrl}
user={user}
authEnabled={authEnabled}
onNavigate={handleNavigatePage}
onOpenSearch={() => setCmdkOpen(true)}
showMenuButton={compactMode}
onOpenDrawer={openNavDrawer}
hamburgerButtonRef={hamburgerRef}
navDrawerOpen={navDrawerOpen}
onNavigateNotificationHref={handleNotificationHref}
onOpenNotificationCenter={(tab) => {
if (!confirmDiscardUnsaved()) return;
setUrlState(prev => ({ ...prev, page: 'notifications', notificationTab: tab }));
}}
/>
}
rail={compactMode ? null : (
<NavRail items={visibleNav} currentPage={page} onNavigate={handleNavigatePage} />
)}
>
{legacyTranscriptionNotice && page === 'spaces' && <div role="status" className="flex-shrink-0 flex items-center gap-3 border-b border-hairline bg-canvas px-4 py-2 text-sm"><p className="flex-1">{t('transcriptionMoved')}</p><button aria-label="閉じる" onClick={() => setLegacyTranscriptionNotice(false)}>×</button></div>}
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} urlState={urlState} setUrlState={setUrlState} workspaceName={spaces?.find(space => space.id === urlState.spaceId)?.title} onOpenWorkspaceSettings={urlState.spaceId ? () => { setSettingsSpaceId(urlState.spaceId!); setUrlState(prev => ({ ...prev, page: 'spaces', spaceTaskId: undefined })); } : undefined} /></div>}
{page === 'spaces' && <div className="flex-1 min-h-0 overflow-hidden"><SpaceRailUrlContext.Provider value={{ taskId: urlState.spaceTaskId, tab: urlState.spaceRailTab, select: (tab, replace) => { const next = { ...urlState, spaceRailTab: tab }; if (replace) replaceUrlSearch(buildUiUrlStateSearch(next)); else setUrlState(next); } }}><SpacesPage spaceId={urlState.spaceId} spaceTaskId={urlState.spaceTaskId} initialTab={settingsSpaceId === urlState.spaceId ? 'settings' : undefined} onInitialTabApplied={() => setSettingsSpaceId(null)} onOpenAppSettings={() => setUrlState(prev => ({ ...prev, page: 'settings', spaceTaskId: undefined }))} chatFilter={{ search: urlState.spaceSearch, status: urlState.spaceStatus, sort: urlState.spaceSort, scope: urlState.spaceScope }} onChatFilterChange={(next) => setUrlState(prev => ({ ...prev, ...(next.search !== undefined ? { spaceSearch: next.search } : {}), ...(next.status !== undefined ? { spaceStatus: next.status } : {}), ...(next.sort !== undefined ? { spaceSort: next.sort } : {}), ...(next.scope !== undefined ? { spaceScope: next.scope } : {}) }))} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined, spaceSearch: '', spaceStatus: 'all', spaceSort: 'updated', spaceScope: 'mine' }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined, spaceRailTab: undefined }))} onCreateTask={handleGlobalCreateTask} onOpenTask={handleOpenTaskInSpace} /></SpaceRailUrlContext.Provider></div>}
{/* 横断アクティビティ(#1360 段 E)。行き先の解決は handleOpenTaskInSpace に任せる
——spaceId が null(スペース以前のチャット)なら個人ワークスペースへ寄る。 */}
{page === 'activity' && <div className="flex-1 min-h-0 overflow-hidden flex flex-col"><ActivityPage onOpenChat={(taskId, spaceIdHint) => handleOpenTaskInSpace(taskId, spaceIdHint)} /></div>}
{page === 'calendar' && <div className="flex-1 min-h-0 overflow-hidden"><CrossSpaceCalendar onOpenSpace={(id) => setUrlState(prev => ({ ...prev, page: 'spaces', spaceId: id, spaceTaskId: undefined }))} onOpenTask={handleOpenTaskInSpace} /></div>}
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage authEnabled={authEnabled} /></div>}
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
{page === 'usage' && <div className="flex-1 min-h-0 overflow-hidden flex flex-col"><UsagePage /></div>}
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
{page === 'notifications' && <NotificationCenterPage tab={urlState.notificationTab ?? 'inbox'} onTabChange={(tab) => setUrlState(prev => ({ ...prev, notificationTab: tab }))} onNavigateHref={handleNotificationHref} />}
</AppShell>
<ToastHost toasts={toasts} onDismiss={dismissToast} />
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} urlState={urlState} setUrlState={setUrlState} workspaceName={spaces?.find(space => space.id === urlState.spaceId)?.title} onOpenWorkspaceSettings={urlState.spaceId ? () => { setSettingsSpaceId(urlState.spaceId!); setUrlState(prev => ({ ...prev, page: 'spaces', spaceTaskId: undefined })); } : undefined} /></div>}
{page === 'spaces' && <div className="flex-1 min-h-0 overflow-hidden"><SpacesPage spaceId={urlState.spaceId} spaceTaskId={urlState.spaceTaskId} initialTab={settingsSpaceId === urlState.spaceId ? 'settings' : undefined} onInitialTabApplied={() => setSettingsSpaceId(null)} onOpenAppSettings={() => setUrlState(prev => ({ ...prev, page: 'settings', spaceTaskId: undefined }))} chatFilter={{ search: urlState.spaceSearch, status: urlState.spaceStatus, sort: urlState.spaceSort, scope: urlState.spaceScope }} onChatFilterChange={(next) => setUrlState(prev => ({ ...prev, ...(next.search !== undefined ? { spaceSearch: next.search } : {}), ...(next.status !== undefined ? { spaceStatus: next.status } : {}), ...(next.sort !== undefined ? { spaceSort: next.sort } : {}), ...(next.scope !== undefined ? { spaceScope: next.scope } : {}) }))} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined, spaceSearch: '', spaceStatus: 'all', spaceSort: 'updated', spaceScope: 'mine' }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined }))} onCreateTask={handleGlobalCreateTask} onOpenTask={handleOpenTaskInSpace} /></div>}
{page === 'calendar' && <div className="flex-1 min-h-0 overflow-hidden"><CrossSpaceCalendar onOpenSpace={(id) => setUrlState(prev => ({ ...prev, page: 'spaces', spaceId: id, spaceTaskId: undefined }))} onOpenTask={handleOpenTaskInSpace} /></div>}
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
{page === 'usage' && <div className="flex-1 min-h-0 overflow-hidden flex flex-col"><UsagePage /></div>}
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
<ToastHost toasts={toasts} onDismiss={dismissToast} durationMs={3500} />
{showCreateDialog && (
@@ -581,5 +639,8 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
/>
<CommandPalette open={cmdkOpen} onClose={() => setCmdkOpen(false)} ctx={cmdkCtx} />
</div>
</WorkerStatusWindowProvider>
</ChatTranscriptionProvider>
</UiInspectorProvider>
);
}
+2
View File
@@ -7,6 +7,7 @@ export * from './api/config';
export * from './api/users';
export * from './api/tasks';
export * from './api/task-files';
export * from './api/task-file-history';
export * from './api/spaces';
export * from './api/space-files';
export * from './api/calendar';
@@ -24,4 +25,5 @@ export * from './api/skills';
export * from './api/notifications';
export * from './api/usage';
export * from './api/delegate';
export * from './api/llm-metrics';
export * from './api/chat-connectors';
+32
View File
@@ -0,0 +1,32 @@
// 横断アクティビティ(#1360 段 E): 所属する全ワークスペースのチャットを、
// 更新時刻の新しい順に引く。サーバ側の口は GET /api/activity/chats。
import { BASE } from './client';
export interface ChatActivityPreviewDTO {
author: string;
kind: string;
/** サーバ側で切り詰め済みの本文。 */
body: string;
createdAt: string;
}
export interface ChatActivityEntryDTO {
taskId: number;
title: string;
/** 並びの鍵。発言以外の更新でも動くので、画面の見出しは「更新」にする。 */
updatedAt: string;
/** null = 個人ワークスペース(スペース以前に作られたチャット)。 */
spaceId: string | null;
spaceTitle: string | null;
ownerId: string | null;
ownerName: string | null;
lastComment: ChatActivityPreviewDTO | null;
}
export async function fetchChatActivity(limit?: number): Promise<ChatActivityEntryDTO[]> {
const query = limit != null ? `?limit=${limit}` : '';
const res = await fetch(`${BASE}/activity/chats${query}`);
const data = await res.json().catch(() => ({} as Record<string, unknown>));
if (!res.ok) throw new Error((data as { error?: string }).error ?? 'failed to fetch chat activity');
return (data as { entries: ChatActivityEntryDTO[] }).entries ?? [];
}
+57
View File
@@ -0,0 +1,57 @@
// issue #1153 段 1-a: 監査記録を読む口の呼び出し(管理者のみ)。
import { BASE, buildQuery } from './client';
export interface AdminAuditLogItemDTO {
id: number;
jobId: string | null;
action: string;
actor: string | null;
/** ISO 8601 の瞬間。 */
createdAt: string;
/** JSON の object として読めたときだけ入る。 */
detail: Record<string, unknown> | null;
/** object として読めなかったときの元の文字列。 */
detailRaw: string | null;
}
export interface AdminAuditLogPageDTO {
items: AdminAuditLogItemDTO[];
nextCursor: string | null;
}
export interface AdminAuditLogFilters {
jobId?: string;
action?: string;
actor?: string;
/** ISO 8601 の瞬間。 */
from?: string;
to?: string;
}
export interface AdminAuditActionDTO { action: string; count: number }
/** Native download keeps the complete log out of the page's JavaScript heap. */
export function adminAuditLogExportUrl(filters: AdminAuditLogFilters): string {
return BASE + '/admin/audit-log/export' + buildQuery({ ...filters });
}
export async function fetchAdminAuditLogPage(
filters: AdminAuditLogFilters,
cursor?: string,
limit = 50,
): Promise<AdminAuditLogPageDTO> {
const params = new URLSearchParams({ limit: String(limit) });
for (const [key, value] of Object.entries(filters)) if (value) params.set(key, value);
if (cursor) params.set('cursor', cursor);
const response = await fetch(`${BASE}/admin/audit-log?${params}`, { credentials: 'include' });
const body = await response.json().catch(() => ({})) as AdminAuditLogPageDTO & { error?: string };
if (!response.ok) throw new Error(body.error ?? 'failed_to_fetch_audit_log');
return body;
}
export async function fetchAdminAuditActions(): Promise<AdminAuditActionDTO[]> {
const response = await fetch(`${BASE}/admin/audit-log/actions`, { credentials: 'include' });
const body = await response.json().catch(() => ({})) as { actions?: AdminAuditActionDTO[]; error?: string };
if (!response.ok) throw new Error(body.error ?? 'failed_to_fetch_audit_actions');
return body.actions ?? [];
}
+22
View File
@@ -0,0 +1,22 @@
import { BASE } from './client';
export interface AdminEventActorDTO { id: string | null; name: string; email: string | null; deleted: boolean }
export interface AdminEventDTO {
id: number; occurredAt: string; eventType: string; actor: AdminEventActorDTO | null;
spaceId: string | null; resourceType: string; resourceId: string; result: string; correlationId: string;
}
export interface AdminEventPageDTO { items: AdminEventDTO[]; nextCursor: string | null }
export interface AdminEventFilters {
from?: string; to?: string; eventType?: string; result?: string; actor?: string;
spaceId?: string; resourceId?: string;
}
export async function fetchAdminEventLogPage(filters: AdminEventFilters, cursor?: string, limit = 50): Promise<AdminEventPageDTO> {
const params = new URLSearchParams({ limit: String(limit) });
for (const [key, value] of Object.entries(filters)) if (value) params.set(key, value);
if (cursor) params.set('cursor', cursor);
const response = await fetch(`${BASE}/admin/event-log?${params}`);
const body = await response.json().catch(() => ({})) as AdminEventPageDTO & { error?: string };
if (!response.ok) throw new Error(body.error ?? 'failed_to_fetch_admin_events');
return body;
}
+49
View File
@@ -0,0 +1,49 @@
// issue #1204 第 6 段: 管理者が外部ファイル置き場の接続を見て切る口。
//
// 一覧に資格情報は載らない。載るのは「誰が・どの登録に・いつ繋いだか」と、
// 切ったときに提供側でも失効させられるかどうかだけ。
import { BASE } from './client';
/** 提供側で何が起きたか。切ったこと自体はどの値でも必ず起きている。 */
export type FileStoreRevokeOutcome = 'revoked' | 'unsupported' | 'not_connected' | 'failed';
export interface AdminFileStoreConnectionDTO {
userId: string;
userEmail: string | null;
appId: string;
appName: string | null;
provider: 'microsoft' | 'box' | null;
spaceId: string | null;
connectedAt: string;
updatedAt: string;
expiresAt: string | null;
scope: string | null;
/** 切ったときに提供側でも失効させられる登録かどうか。 */
revocable: boolean;
}
export async function fetchAdminFileStoreConnections(): Promise<AdminFileStoreConnectionDTO[]> {
const response = await fetch(`${BASE}/admin/filestore/connections`, { credentials: 'include' });
const body = (await response.json().catch(() => ({}))) as {
connections?: AdminFileStoreConnectionDTO[];
error?: string;
};
if (!response.ok) throw new Error(body.error ?? 'failed_to_fetch_filestore_connections');
return body.connections ?? [];
}
export async function cutAdminFileStoreConnection(
userId: string,
appId: string,
): Promise<FileStoreRevokeOutcome> {
const response = await fetch(
`${BASE}/admin/filestore/connections/${encodeURIComponent(userId)}/${encodeURIComponent(appId)}`,
{ method: 'DELETE', credentials: 'include' },
);
const body = (await response.json().catch(() => ({}))) as {
revoke?: FileStoreRevokeOutcome;
error?: string;
};
if (!response.ok) throw new Error(body.error ?? 'failed_to_cut_filestore_connection');
return body.revoke ?? 'not_connected';
}
+46
View File
@@ -0,0 +1,46 @@
// 保存の応答に「どちらの道で保存したか」の印が無いときの倒し方
// (issue #1148 レビュー P3-3)。
//
// 印が読めないときに「生きた画面から保存した」と読むと、知らせが出ない側に倒れる。
// 中身の入っていないログイン情報が黙って「有効」として並ぶより、知らせを出しすぎる
// ほうが軽い。サーバーと画面は一緒に出荷されるので今は起きないが、倒す向きの話。
import { afterEach, describe, expect, it, vi } from 'vitest';
import { saveBrowserSession } from './browser-sessions';
function respondWith(body: unknown): void {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => body,
})));
}
afterEach(() => { vi.unstubAllGlobals(); });
const profile = { id: 7, label: 'My Site' };
describe('saveBrowserSession — 印が読めないとき', () => {
it('印が無ければ「控えから」に倒す(知らせを出す側)', async () => {
respondWith({ profile });
const r = await saveBrowserSession(7, 'sess-1');
expect(r.savedFrom).toBe('snapshot');
});
it('知らない値でも「控えから」に倒す', async () => {
respondWith({ profile, savedFrom: 'cached' });
const r = await saveBrowserSession(7, 'sess-1');
expect(r.savedFrom).toBe('snapshot');
});
it('「生きた画面から」と書かれていればそのまま', async () => {
respondWith({ profile, savedFrom: 'live' });
const r = await saveBrowserSession(7, 'sess-1');
expect(r.savedFrom).toBe('live');
});
it('「控えから」と書かれていれば、控えを取った時刻も持ち帰る', async () => {
respondWith({ profile, savedFrom: 'snapshot', snapshotAt: '2026-08-18T01:02:03.000Z' });
const r = await saveBrowserSession(7, 'sess-1');
expect(r.savedFrom).toBe('snapshot');
expect(r.snapshotAt).toBe('2026-08-18T01:02:03.000Z');
});
});
+23 -2
View File
@@ -54,14 +54,35 @@ export async function startBrowserSessionLogin(id: number): Promise<{ sessionId:
return r.json();
}
export async function saveBrowserSession(id: number, sessionId: string): Promise<BrowserSessionProfile> {
/**
* ログイン情報の保存結果。
*
* `savedFrom` が `snapshot` のときは、人が窓を閉じたあとに残っていた控えから
* 保存している。控えは最大 1 秒古いので、ログインが入り切っていないことがある。
* 画面に知らせを出すために返している (issue #1148 レビュー P1-1)。
*/
export interface SaveBrowserSessionResult {
profile: BrowserSessionProfile;
savedFrom: 'live' | 'snapshot';
/** 控えを取った時刻 (ISO 8601)。控えから保存したときだけ入る */
snapshotAt?: string;
}
export async function saveBrowserSession(
id: number,
sessionId: string,
): Promise<SaveBrowserSessionResult> {
const r = await fetch(`${SESS_BASE}/profiles/${id}/save`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
});
if (!r.ok) throw new Error(`saveBrowserSession: ${r.status}`);
return (await r.json() as { profile: BrowserSessionProfile }).profile;
const body = await r.json() as SaveBrowserSessionResult;
// 印が読めないときは「控えから」に倒す。知らせを出しすぎるほうが、
// 中身の入っていないログイン情報を黙って「有効」として並べるより軽い
// (issue #1148 レビュー P3-3)。
return { ...body, savedFrom: body.savedFrom === 'live' ? 'live' : 'snapshot' };
}
export async function cancelBrowserSession(id: number, sessionId: string): Promise<void> {
+102
View File
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createCalendarEvent,
fetchCrossSpaceCalendarMonth,
fetchSpaceCalendarMonth,
updateCalendarEvent,
} from "./calendar";
afterEach(() => vi.unstubAllGlobals());
describe("calendar range API", () => {
it("sends month, range, and offset", async () => {
const f = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ days: {}, events: [], spaces: [] }) }); vi.stubGlobal("fetch", f);
await fetchSpaceCalendarMonth("s1", "2026-07", "2026-06-28", "2026-08-08", 540);
await fetchCrossSpaceCalendarMonth("2026-07", "2026-06-28", "2026-08-08", -480);
expect(f.mock.calls[0][0]).toContain("month=2026-07&from=2026-06-28&to=2026-08-08&tz_offset=540");
expect(f.mock.calls[1][0]).toContain("month=2026-07&from=2026-06-28&to=2026-08-08&tz_offset=-480");
});
});
// #1395: 通知つきの予定はサーバが timezone_id を必須にしている。画面が組み立てた中身が
// 実際にリクエストの本体へ載るところまで見る。予定の作成・更新はこの 2 関数が唯一の出口で、
// 呼び出し元は SpaceCalendar.tsx の 1 か所だけなので、ここが配線の最終地点になる。
const stubFetch = () => {
const f = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: 1 }) });
vi.stubGlobal("fetch", f);
return f;
};
// 実行環境の時間帯に依存しないよう、あり得ない値を差し込んで「これが載る」ことを見る。
const stubTimeZone = (timeZone: string) => {
// Intl の項目は列挙可能ではないので `{...Intl}` は空になる(実測済み)。
// 土台に Intl そのものを敷いて、DateTimeFormat だけを差し替える。
const stub = Object.create(Intl) as typeof Intl;
Object.defineProperty(stub, "DateTimeFormat", {
value: () => ({ resolvedOptions: () => ({ timeZone }) }),
configurable: true,
writable: true,
});
vi.stubGlobal("Intl", stub);
};
const bodyOf = (f: ReturnType<typeof stubFetch>, call = 0): Record<string, unknown> =>
JSON.parse((f.mock.calls[call][1] as { body: string }).body);
describe("calendar event API: timezone_id", () => {
it("includes the viewer time zone when creating an event with a reminder", async () => {
const f = stubFetch();
stubTimeZone("Pacific/Apia");
await createCalendarEvent("s1", {
date: "2026-09-05", time: "09:00", reminderMinutes: 5, title: "打ち合わせ",
});
const body = bodyOf(f);
expect(body.timezone_id).toBe("Pacific/Apia");
expect(body.reminder_minutes).toBe(5);
// 夏時間で同じ時刻が 2 回来る帯は、サーバが「面倒を見る」のではなく 400 で断る
// (src/reminders/time.ts の ambiguous local time → space-calendar-api が 400 に変換)。
// 画面にどちらかを選ぶ操作子が無いので、ここでは指定を送らない。**穴が残ることを
// 承知のうえで残している** — 送っても選べないので結果は変わらない。
expect("disambiguation" in body).toBe(false);
});
it("treats a 0-minute reminder as a reminder", async () => {
const f = stubFetch();
stubTimeZone("Pacific/Apia");
await createCalendarEvent("s1", {
date: "2026-09-05", time: "09:00", reminderMinutes: 0, title: "打ち合わせ",
});
expect(bodyOf(f).timezone_id).toBe("Pacific/Apia");
});
it("omits the time zone when creating an event without a reminder", async () => {
const f = stubFetch();
stubTimeZone("Pacific/Apia");
await createCalendarEvent("s1", {
date: "2026-09-05", time: "09:00", reminderMinutes: null, title: "打ち合わせ",
});
expect("timezone_id" in bodyOf(f)).toBe(false);
});
it("includes the viewer time zone when a reminder is added by update", async () => {
const f = stubFetch();
stubTimeZone("Pacific/Apia");
await updateCalendarEvent("s1", 7, { time: "09:00", reminderMinutes: 30 });
const body = bodyOf(f);
expect(body.timezone_id).toBe("Pacific/Apia");
expect(body.reminder_minutes).toBe(30);
expect("disambiguation" in body).toBe(false);
});
it("omits the time zone when an update clears the reminder", async () => {
const f = stubFetch();
stubTimeZone("Pacific/Apia");
await updateCalendarEvent("s1", 7, { reminderMinutes: null });
const body = bodyOf(f);
expect(body.reminder_minutes).toBeNull();
expect("timezone_id" in body).toBe(false);
});
it("omits the time zone when an update leaves the reminder untouched", async () => {
const f = stubFetch();
stubTimeZone("Pacific/Apia");
await updateCalendarEvent("s1", 7, { title: "改題" });
expect("timezone_id" in bodyOf(f)).toBe(false);
});
});
+29 -15
View File
@@ -69,9 +69,11 @@ export interface CrossCalendarMonth {
export async function fetchCrossSpaceCalendarMonth(
month: string,
rangeStart: string,
rangeEnd: string,
tzOffset: number,
): Promise<CrossCalendarMonth> {
const params = new URLSearchParams({ month, tz_offset: String(tzOffset) });
const params = new URLSearchParams({ month, from: rangeStart, to: rangeEnd, tz_offset: String(tzOffset) });
const res = await fetch(`${BASE}/calendar/cross?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar');
@@ -81,9 +83,11 @@ export async function fetchCrossSpaceCalendarMonth(
export async function fetchSpaceCalendarMonth(
spaceId: string,
month: string,
rangeStart: string,
rangeEnd: string,
tzOffset: number,
): Promise<CalendarMonth> {
const params = new URLSearchParams({ month, tz_offset: String(tzOffset) });
const params = new URLSearchParams({ month, from: rangeStart, to: rangeEnd, tz_offset: String(tzOffset) });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar');
@@ -102,15 +106,32 @@ export async function fetchSpaceCalendarDay(
return data as CalendarDay;
}
/**
* 閲覧している人の環境の IANA タイムゾーン識別子(例 'Asia/Tokyo')。
* サーバは通知(reminder_minutes)が付いた予定にだけこれを必須にしており、
* 無いと 400 で拒否する(src/bridge/space-calendar-api.ts の timezone_id 検証)。
*/
export function resolveTimeZoneId(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
export async function createCalendarEvent(
spaceId: string,
input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, endTime, reminderMinutes, ...rest } = input;
const body: Record<string, unknown> = {
...rest,
end_date: endDate ?? null,
end_time: endTime ?? null,
reminder_minutes: reminderMinutes ?? null,
};
// 通知があるときだけ送る。0(開始時)も通知なので != null で判定する。
if (reminderMinutes != null) body.timezone_id = resolveTimeZoneId();
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null, reminder_minutes: reminderMinutes ?? null }),
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create event');
@@ -126,7 +147,11 @@ export async function updateCalendarEvent(
const body: Record<string, unknown> = { ...rest };
if (endDate !== undefined) body.end_date = endDate;
if (endTime !== undefined) body.end_time = endTime;
if (reminderMinutes !== undefined) body.reminder_minutes = reminderMinutes;
if (reminderMinutes !== undefined) {
body.reminder_minutes = reminderMinutes;
// 通知を外す(null)ときは送らない。サーバが通知欄をまとめて空にする。
if (reminderMinutes != null) body.timezone_id = resolveTimeZoneId();
}
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -146,14 +171,3 @@ export async function deleteCalendarEvent(spaceId: string, eventId: number): Pro
throw new Error(err.error || res.statusText);
}
}
export async function claimSpaceCalendarReminders(spaceId: string): Promise<CalendarEvent[]> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/reminders/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to claim calendar reminders');
return data.events as CalendarEvent[];
}
+14 -3
View File
@@ -8,7 +8,7 @@
// --- Chat connector bindings ------------------------------------------------
export type ChatConnectorPlatform = 'slack';
export type ChatConnectorPlatform = 'slack' | 'discord';
export type ChatConnectorBindingStatus = 'active' | 'disabled';
/**
@@ -28,19 +28,30 @@ export interface ChatConnectorBinding {
createdBy: string | null;
createdAt: string;
updatedAt: string;
/** Live connection health (Discord gateway today; null for platforms without one, e.g. Slack). */
connectionState?: string | null;
lastError?: string | null;
lastConnectedAt?: string | null;
}
/**
* Bot credentials are platform-flexible: `botToken` is always required, while
* `signingSecret` only applies to Slack (HTTP Events API request verification).
* Discord's gateway connection is authenticated by the bot token alone.
*/
export type ChatConnectorBotCredentials = { signingSecret?: string; botToken: string };
export interface CreateChatConnectorBindingInput {
platform: ChatConnectorPlatform;
externalWorkspaceId: string;
externalChannelId: string;
a2aDelegationId: string;
botCredentials: { signingSecret: string; botToken: string };
botCredentials: ChatConnectorBotCredentials;
}
export interface UpdateChatConnectorBindingInput {
status?: ChatConnectorBindingStatus;
botCredentials?: { signingSecret: string; botToken: string };
botCredentials?: ChatConnectorBotCredentials;
}
async function readErrorMessage(res: Response): Promise<string> {
+76
View File
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { updateConfig } from './config';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('updateConfig numeric reset wire format', () => {
it('serializes explicitly cleared saved numbers as null without rewriting atomic values', async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
const baseline = {
concurrency: 4,
llm: {
maxStreamMinutes: 20,
maxThinkingChars: 500,
workers: [{
id: 'gpu1',
contextWindow: 32_768,
apiKey: '********',
extraBody: { top_k: 20 },
}],
},
synthetic: { extraBody: { temperature: 0.3 } },
router: { default: 'gpu1' },
};
const draft = {
concurrency: undefined,
llm: {
maxStreamMinutes: undefined,
maxThinkingChars: 0,
neverConfigured: undefined,
workers: [{
id: 'gpu1',
contextWindow: undefined,
apiKey: '********',
extraBody: { top_k: undefined },
}],
},
synthetic: {
unsetNumber: undefined,
extraBody: { temperature: undefined },
},
router: null,
};
await updateConfig(draft, 'etag-1', baseline);
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(init.headers).toEqual({ 'Content-Type': 'application/json', 'If-Match': 'etag-1' });
expect(JSON.parse(String(init.body))).toEqual({
concurrency: null,
llm: {
maxStreamMinutes: null,
maxThinkingChars: 0,
workers: [{ id: 'gpu1', apiKey: '********', extraBody: {} }],
},
synthetic: { extraBody: {} },
router: null,
});
expect(draft.llm.maxStreamMinutes).toBeUndefined();
expect(baseline.llm.maxStreamMinutes).toBe(20);
});
it('surfaces the server rejection message to the existing error toast', async () => {
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(JSON.stringify({ ok: false, message: 'llm.maxStreamMinutes must be within the timer range' }), { status: 400 }),
));
await expect(updateConfig({ llm: { maxStreamMinutes: -1 } }, 'etag-1', {}))
.rejects.toThrow('llm.maxStreamMinutes must be within the timer range');
});
});
+104 -5
View File
@@ -58,23 +58,86 @@ export function serializeSecretValue(v: SecretFieldValue): string {
return '';
}
/**
* Observed isolation state reported by `GET /api/config` (issue #1215).
* NOT a setting — it is the server's own probe result, kept beside `config`
* so it never rides back in a PUT.
*/
export interface SandboxStatus {
setting: 'auto' | 'always' | 'off';
bwrapAvailable: boolean;
bwrapReason?: string;
checkedAt: string | null;
effectiveMode: 'sandboxed' | 'hardened';
networkBlockConfigured: boolean;
networkBlockEffective: boolean;
unrestricted: boolean;
networkBlockIneffective: boolean;
}
// --- Config ---
export async function fetchConfig(): Promise<{ config: any; etag: string; overriddenByEnv: Record<string, boolean> }> {
export interface BrowserEgressPolicy {
mode: 'off' | 'report-only' | 'enforce';
connectOrigins: string[];
imageOrigins: string[];
frameOrigins: string[];
}
/** Shape persisted by the shared GET/PUT /api/config draft. */
export interface EgressPolicy {
version: 1;
observeNode: boolean;
browser: BrowserEgressPolicy;
}
export async function fetchConfig(): Promise<{ config: any; etag: string; overriddenByEnv: Record<string, boolean>; sandboxStatus: SandboxStatus | null }> {
const res = await fetch(`${BASE}/config`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch config');
return { config: data.config, etag: res.headers.get('etag') ?? '', overriddenByEnv: data.overriddenByEnv ?? {} };
return {
config: data.config,
etag: res.headers.get('etag') ?? '',
overriddenByEnv: data.overriddenByEnv ?? {},
sandboxStatus: (data.sandboxStatus as SandboxStatus | undefined) ?? null,
};
}
export async function updateConfig(config: any, etag: string): Promise<{ ok: boolean; conflict?: boolean }> {
const isPlainConfigObject = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
/**
* JSON drops `undefined`, but an explicitly cleared optional number must reach
* the server as a reset marker. Only values that were numbers in the GET
* snapshot tied to this ETag become null. Arrays and opaque extraBody objects
* keep their existing whole-value serialization contract.
*/
export function prepareConfigUpdate(draft: unknown, baseline: unknown): unknown {
if (!isPlainConfigObject(draft)) return draft;
const saved = isPlainConfigObject(baseline) ? baseline : {};
const prepared: Record<string, unknown> = {};
for (const key of Object.keys(draft)) {
const value = draft[key];
const savedValue = saved[key];
if (value === undefined && typeof savedValue === 'number') {
prepared[key] = null;
} else if (key !== 'extraBody' && isPlainConfigObject(value) && isPlainConfigObject(savedValue)) {
prepared[key] = prepareConfigUpdate(value, savedValue);
} else {
prepared[key] = value;
}
}
return prepared;
}
export async function updateConfig(config: any, etag: string, baseline: any = {}): Promise<{ ok: boolean; conflict?: boolean }> {
const res = await fetch(`${BASE}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'If-Match': etag },
body: JSON.stringify(config),
body: JSON.stringify(prepareConfigUpdate(config, baseline)),
});
const data = await res.json();
if (res.status === 409) return { ok: false, conflict: true };
if (!res.ok) throw new Error(data?.error ?? 'Failed to update config');
if (!res.ok) throw new Error(data?.error ?? data?.message ?? 'Failed to update config');
return data;
}
@@ -82,3 +145,39 @@ export async function reloadConfig(): Promise<void> {
const res = await fetch(`${BASE}/config/reload`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to reload config');
}
export interface WorkerProbeResult {
modelsAvailable?: boolean;
chat?: {
model: string;
ok: boolean;
httpStatus?: number;
error?: string;
} & import('../../../src/shared/provider-error').LlmFailureDetails | null;
normalizedEndpoint: string;
model: string | null;
models: string[];
contextWindow: number | null;
tokenizer: { endpoint: string; ok: boolean; requiresApiKey: boolean } | null;
toolCalling: boolean | null;
slots: number | null;
latencyMs: number;
errors: string[];
}
export async function probeWorkerConfig(endpoint: string, apiKey?: string, model?: string, tokenizerApiKey?: string, connectionType: 'direct' | 'aao_gateway' | 'external_api' = 'direct'): Promise<WorkerProbeResult> {
const res = await fetch(`${BASE}/config/probe-worker`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint,
...(apiKey ? { api_key: apiKey } : {}),
...(model?.trim() ? { model: model.trim() } : {}),
...(tokenizerApiKey ? { tokenizer_api_key: tokenizerApiKey } : {}),
connection_type: connectionType,
}),
});
const data = await res.json().catch(() => null);
if (!res.ok) throw new Error(data?.error ?? 'Failed to detect worker settings');
return data as WorkerProbeResult;
}
+383
View File
@@ -0,0 +1,383 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
DictionaryHttpError,
lookupDictionary,
type DictionaryLookupEvent,
} from './dictionary';
const encoder = new TextEncoder();
function sseResponse(chunks: string[], includeWorker = true): Response {
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
if (includeWorker && !chunks.some(chunk => chunk.includes('event: worker'))) {
controller.enqueue(encoder.encode('event: worker\ndata: {"workerId":"dictionary-test","fallback":false}\n\n'));
}
chunks.forEach(chunk => controller.enqueue(encoder.encode(chunk)));
controller.close();
},
}), {
status: 200,
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
});
}
beforeEach(() => {
vi.restoreAllMocks();
});
describe('lookupDictionary', () => {
it.each([
['dictionary_worker_not_configured', 503],
['dictionary_worker_unavailable', 503],
] as const)('preserves the safe HTTP diagnostic code %s', async (code, status) => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify({ error: code, message: 'secret server detail' }),
{ status, headers: { 'Content-Type': 'application/json' } },
));
const error = await lookupDictionary({ text: 'term-' + code, mode: 'llm', uiLang: 'en' }).catch(value => value);
expect(error).toBeInstanceOf(DictionaryHttpError);
expect(error).toMatchObject({ code, status });
expect(error.message).not.toContain('secret server detail');
});
it.each([
['unknown code', { error: 'database_password=secret' }, 503],
['malformed body', { error: { nested: 'secret' } }, 503],
['old backend', { error: 'not found' }, 404],
])('does not expose %s HTTP response details', async (_case, body, status) => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify(body),
{ status, headers: { 'Content-Type': 'application/json' } },
));
const error = await lookupDictionary({ text: 'safe-' + _case, mode: 'llm', uiLang: 'en' }).catch(value => value);
expect(error).toBeInstanceOf(DictionaryHttpError);
expect(error).toMatchObject({ code: 'dictionary_unknown_error' });
expect(error.message).not.toContain('secret');
expect(error.message).not.toContain('not found');
});
it('posts the normalized request and parses split SSE frames', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(sseResponse([
'event: worker\ndata: {"workerId":"dictionary-primary","fallback":false}\n\nevent: status\ndata: {"phase":"search',
'ing"}\n\nevent: delta\ndata: {"text":"分散"}\n\n',
'event: prompt_progress\ndata: {"percent":42}\n\nevent: think_delta\ndata: {"text":"内部推論"}\n\nevent: delta\ndata: {"text":"処理"}\n\nevent: sources\ndata: {"items":[{"title":"Source","url":"https://example.com"}]}\n\n',
'event: done\ndata: {}\n\n',
]));
const events: DictionaryLookupEvent[] = [];
const result = await lookupDictionary({
text: ' 分散 処理 ',
context: ' 文脈 です ',
mode: 'web',
uiLang: 'ja',
}, { onEvent: event => events.push(event) });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith('/api/dictionary/lookup', expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({
text: '分散 処理',
context: '文脈 です',
mode: 'web',
uiLang: 'ja',
}),
}));
expect(events.map(event => event.type)).toEqual(['worker', 'status', 'delta', 'prompt_progress', 'think_delta', 'delta', 'sources', 'done']);
expect(events).toContainEqual({ type: 'prompt_progress', data: { percent: 42 } });
expect(events).toContainEqual({ type: 'think_delta', data: { text: '内部推論' } });
expect(result).toEqual({
text: '分散処理',
sources: [{ title: 'Source', url: 'https://example.com' }],
worker: { workerId: 'dictionary-primary', fallback: false },
});
});
it('parses CRLF frame separators split across stream chunks', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(sseResponse([
'event: delta\r',
'\ndata: {"text":"CRLF"}\r',
'\n\r',
'\nevent: done\r\ndata: {}\r\n\r\n',
]));
await expect(lookupDictionary({
text: 'line endings',
mode: 'llm',
uiLang: 'en',
})).resolves.toEqual({ text: 'CRLF', sources: [], worker: { workerId: 'dictionary-test', fallback: false } });
});
it('設定変更後に古いworkerの完了回答を再利用せず、同じ語でも再取得する', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(sseResponse([
'event: worker\ndata: {"workerId":"dictionary-a","fallback":false}\n\nevent: delta\ndata: {"text":"old answer"}\n\n',
'event: done\ndata: {}\n\n',
])).mockResolvedValueOnce(sseResponse([
'event: worker\ndata: {"workerId":"dictionary-b","fallback":false}\n\nevent: delta\ndata: {"text":"new answer"}\n\n',
'event: done\ndata: {}\n\n',
]));
await lookupDictionary({ text: 'term', mode: 'llm', uiLang: 'en' });
const events: DictionaryLookupEvent[] = [];
const result = await lookupDictionary(
{ text: ' term ', mode: 'llm', uiLang: 'en' },
{ onEvent: event => events.push(event) },
);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(result).toMatchObject({ text: 'new answer', worker: { workerId: 'dictionary-b', fallback: false } });
expect(events).toEqual([
{ type: 'worker', data: { workerId: 'dictionary-b', fallback: false } },
{ type: 'delta', data: { text: 'new answer' } },
{ type: 'done', data: {} },
]);
});
it('不正な worker SSE を拒否してサーバーのworkerを推測しない', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(sseResponse([
'event: worker\ndata: {"workerId":42,"fallback":"no"}\n\n',
'event: done\ndata: {}\n\n',
]));
await expect(lookupDictionary({ text: 'term', mode: 'llm', uiLang: 'en' }))
.rejects.toThrow('Invalid dictionary worker event');
});
it('workerなしでdoneに到達したストリームを拒否する', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(sseResponse([
'event: done\ndata: {}\n\n',
], false));
await expect(lookupDictionary({ text: 'term', mode: 'llm', uiLang: 'en' }))
.rejects.toThrow('Dictionary stream missing worker event');
});
it('workerより前に本文イベントがあるストリームを拒否する', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(sseResponse([
'event: delta\ndata: {"text":"answer"}\n\n',
'event: worker\ndata: {"workerId":"dictionary","fallback":false}\n\n',
'event: done\ndata: {}\n\n',
], false));
await expect(lookupDictionary({ text: 'term', mode: 'llm', uiLang: 'en' }))
.rejects.toThrow('Dictionary worker event must be first');
});
it('上限を超えるworkerIdを拒否する', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(sseResponse([
`event: worker\ndata: {"workerId":"${'x'.repeat(129)}","fallback":false}\n\n`,
'event: done\ndata: {}\n\n',
], false));
await expect(lookupDictionary({ text: 'term', mode: 'llm', uiLang: 'en' }))
.rejects.toThrow('Invalid dictionary worker event');
});
it('does not reuse the cache when the same text is selected in a different context', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(sseResponse([
'event: delta\ndata: {"text":"銀行 (金融機関) の説明"}\n\n',
'event: done\ndata: {}\n\n',
]))
.mockResolvedValueOnce(sseResponse([
'event: delta\ndata: {"text":"土手 (川岸) の説明"}\n\n',
'event: done\ndata: {}\n\n',
]))
.mockResolvedValueOnce(sseResponse([
'event: delta\ndata: {"text":"土手 (川岸) の再取得説明"}\n\n',
'event: done\ndata: {}\n\n',
]));
const first = await lookupDictionary({
text: 'bank', context: 'deposit money at the bank', mode: 'llm', uiLang: 'en',
});
const second = await lookupDictionary({
text: 'bank', context: 'sat on the river bank', mode: 'llm', uiLang: 'en',
});
// 文脈が違えば別クエリ。同一文脈でもworker設定変更に備えて完了回答は再利用しない。
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(first.text).not.toBe(second.text);
const replay = await lookupDictionary({
text: 'bank', context: ' sat on the river bank ', mode: 'llm', uiLang: 'en',
});
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(replay.text).not.toBe(second.text);
});
it('keeps mode:text caches separate for each UI language', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(sseResponse([
'event: delta\ndata: {"text":"日本語"}\n\n',
'event: done\ndata: {}\n\n',
]))
.mockResolvedValueOnce(sseResponse([
'event: delta\ndata: {"text":"English"}\n\n',
'event: done\ndata: {}\n\n',
]));
const ja = await lookupDictionary({ text: 'term', mode: 'llm', uiLang: 'ja' });
const en = await lookupDictionary({ text: 'term', mode: 'llm', uiLang: 'en' });
expect(ja.text).toBe('日本語');
expect(en.text).toBe('English');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('同じ入力の並行lookupを独立fetchし、設定切替後のworkerと回答を混同しない', async () => {
let firstStream!: ReadableStreamDefaultController<Uint8Array>;
let secondStream!: ReadableStreamDefaultController<Uint8Array>;
const response = (capture: (controller: ReadableStreamDefaultController<Uint8Array>) => void) =>
new Response(new ReadableStream<Uint8Array>({ start: capture }), {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(response(controller => { firstStream = controller; }))
.mockResolvedValueOnce(response(controller => { secondStream = controller; }));
const first = lookupDictionary({ text: 'same term', mode: 'llm', uiLang: 'en' });
const second = lookupDictionary({ text: 'same term', mode: 'llm', uiLang: 'en' });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const firstSignal = fetchMock.mock.calls[0]?.[1]?.signal;
const secondSignal = fetchMock.mock.calls[1]?.[1]?.signal;
expect(firstSignal).not.toBe(secondSignal);
firstStream.enqueue(encoder.encode('event: worker\ndata: {"workerId":"dictionary-a","fallback":false}\n\nevent: delta\ndata: {"text":"answer-a"}\n\nevent: done\ndata: {}\n\n'));
firstStream.close();
secondStream.enqueue(encoder.encode('event: worker\ndata: {"workerId":"dictionary-b","fallback":true}\n\nevent: delta\ndata: {"text":"answer-b"}\n\nevent: done\ndata: {}\n\n'));
secondStream.close();
await expect(first).resolves.toMatchObject({
text: 'answer-a', worker: { workerId: 'dictionary-a', fallback: false },
});
await expect(second).resolves.toMatchObject({
text: 'answer-b', worker: { workerId: 'dictionary-b', fallback: true },
});
});
it.each([
['invalid worker', [
{ done: false, value: encoder.encode('event: worker\ndata: {"workerId":42,"fallback":false}\n\n') },
], 'Invalid dictionary worker event'],
['error event', [
{ done: false, value: encoder.encode('event: worker\ndata: {"workerId":"dictionary","fallback":false}\n\nevent: error\ndata: {"message":"failed"}\n\n') },
], 'failed'],
['stream ended', [
{ done: false, value: encoder.encode('event: worker\ndata: {"workerId":"dictionary","fallback":false}\n\nevent: delta\ndata: {"text":"partial"}\n\n') },
{ done: true, value: undefined },
], 'before done'],
])('stream %sでreaderをcancelし共有requestをabortする', async (_case, reads, message) => {
const cancel = vi.fn().mockResolvedValue(undefined);
const reader = {
read: vi.fn(),
cancel,
releaseLock: vi.fn(),
};
reads.forEach(read => reader.read.mockResolvedValueOnce(read));
let signal: AbortSignal | undefined;
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
signal = init?.signal as AbortSignal;
return { ok: true, body: { getReader: () => reader } } as unknown as Response;
});
await expect(lookupDictionary({ text: `broken ${_case}`, mode: 'llm', uiLang: 'en' })).rejects.toThrow(message);
expect(cancel).toHaveBeenCalledTimes(1);
expect(signal?.aborted).toBe(true);
});
it('does not start a fetch when the signal is already aborted', async () => {
const controller = new AbortController();
const fetchMock = vi.spyOn(globalThis, 'fetch');
controller.abort();
await expect(lookupDictionary(
{ text: 'term', mode: 'llm', uiLang: 'en' },
{ signal: controller.signal },
)).rejects.toMatchObject({ name: 'AbortError' });
expect(fetchMock).not.toHaveBeenCalled();
});
it('supports caller cancellation with AbortController', async () => {
let streamController!: ReadableStreamDefaultController<Uint8Array>;
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
new ReadableStream<Uint8Array>({
start(controller) {
streamController = controller;
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
));
const controller = new AbortController();
const promise = lookupDictionary(
{ text: 'cancel me', mode: 'llm', uiLang: 'en' },
{ signal: controller.signal },
);
await Promise.resolve();
const rejected = expect(promise).rejects.toMatchObject({ name: 'AbortError' });
controller.abort();
await rejected;
});
it('starts a fresh fetch when an aborted lookup immediately retries the same input', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockImplementationOnce((_url, init) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal as AbortSignal;
signal.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
}))
.mockResolvedValueOnce(sseResponse([
'event: delta\ndata: {"text":"fresh"}\n\n',
'event: done\ndata: {}\n\n',
]));
const controller = new AbortController();
const first = lookupDictionary(
{ text: 'retry term', mode: 'llm', uiLang: 'en' },
{ signal: controller.signal },
);
await Promise.resolve();
controller.abort();
const retry = lookupDictionary({ text: 'retry term', mode: 'llm', uiLang: 'en' });
await expect(first).rejects.toMatchObject({ name: 'AbortError' });
await expect(retry).resolves.toMatchObject({ text: 'fresh' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('turns SSE error events into rejected lookups', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(sseResponse([
'event: error\ndata: {"message":"lookup failed"}\n\n',
]));
await expect(lookupDictionary({
text: 'term',
mode: 'llm',
uiLang: 'en',
})).rejects.toThrow('lookup failed');
});
it('rejects and does not cache a stream that ends before done', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(sseResponse([
'event: delta\ndata: {"text":"partial"}\n\n',
]))
.mockResolvedValueOnce(sseResponse([
'event: delta\ndata: {"text":"complete"}\n\n',
'event: done\ndata: {}\n\n',
]));
await expect(lookupDictionary({
text: 'interrupted',
mode: 'llm',
uiLang: 'en',
})).rejects.toThrow('before done');
await expect(lookupDictionary({
text: 'interrupted',
mode: 'llm',
uiLang: 'en',
})).resolves.toMatchObject({ text: 'complete' });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
+248
View File
@@ -0,0 +1,248 @@
import {
normalizeDictionaryContext,
normalizeDictionarySelectionText,
} from '../lib/dictionarySelection';
export type DictionaryMode = 'llm' | 'web';
export type DictionaryUiLang = 'ja' | 'en';
export type DictionaryHttpErrorCode =
| 'dictionary_worker_not_configured'
| 'dictionary_worker_unavailable'
| 'dictionary_preflight_not_configured'
| 'dictionary_unknown_error';
const KNOWN_DICTIONARY_HTTP_ERROR_CODES = new Set<DictionaryHttpErrorCode>([
'dictionary_worker_not_configured',
'dictionary_worker_unavailable',
'dictionary_preflight_not_configured',
]);
export class DictionaryHttpError extends Error {
constructor(public readonly code: DictionaryHttpErrorCode, public readonly status: number) {
super('Dictionary lookup failed (' + status + ')');
this.name = 'DictionaryHttpError';
}
}
export interface DictionarySource {
title: string;
url: string;
}
export interface DictionaryWorker {
workerId: string;
fallback: boolean;
}
/** `busy` means the worker is full and this lookup is queued, not yet sent. */
export type DictionaryStatus = { phase: 'searching' } | { phase: 'busy'; workerId: string };
export type DictionaryLookupEvent =
| { type: 'status'; data: DictionaryStatus }
| { type: 'prompt_progress'; data: { percent: number } }
| { type: 'think_delta'; data: { text: string } }
| { type: 'delta'; data: { text: string } }
| { type: 'sources'; data: { items: DictionarySource[] } }
| { type: 'worker'; data: DictionaryWorker }
| { type: 'done'; data: Record<string, never> }
| { type: 'error'; data: { message: string } };
export interface DictionaryLookupRequest {
text: string;
context?: string;
mode: DictionaryMode;
uiLang: DictionaryUiLang;
}
export interface DictionaryLookupResult {
text: string;
sources: DictionarySource[];
worker: DictionaryWorker;
}
export interface DictionaryLookupOptions {
signal?: AbortSignal;
onEvent?: (event: DictionaryLookupEvent) => void;
}
const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError');
function isDictionaryWorker(data: unknown): data is DictionaryWorker {
return typeof data === 'object' && data !== null
&& typeof (data as DictionaryWorker).workerId === 'string'
&& (data as DictionaryWorker).workerId.trim().length > 0
&& Array.from((data as DictionaryWorker).workerId).length <= 128
&& typeof (data as DictionaryWorker).fallback === 'boolean';
}
function parseEvent(frame: string): DictionaryLookupEvent | null {
const dataLines: string[] = [];
let eventName = '';
for (const line of frame.split('\n')) {
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart());
}
if (!eventName || dataLines.length === 0) return null;
const data = JSON.parse(dataLines.join('\n')) as unknown;
if (eventName === 'worker') {
if (!isDictionaryWorker(data)) {
throw new Error('Invalid dictionary worker event');
}
return { type: 'worker', data };
}
if (eventName === 'status') {
return { type: 'status', data: data as DictionaryStatus };
}
if (eventName === 'prompt_progress') {
return { type: 'prompt_progress', data: data as { percent: number } };
}
if (eventName === 'think_delta') {
return { type: 'think_delta', data: data as { text: string } };
}
if (eventName === 'delta') {
return { type: 'delta', data: data as { text: string } };
}
if (eventName === 'sources') {
return { type: 'sources', data: data as { items: DictionarySource[] } };
}
if (eventName === 'done') {
return { type: 'done', data: {} };
}
if (eventName === 'error') {
return { type: 'error', data: data as { message: string } };
}
return null;
}
async function readSse(
response: Response,
emit: (event: DictionaryLookupEvent) => void, signal: AbortSignal,
): Promise<DictionaryLookupResult> {
if (!response.body) throw new Error('Dictionary response has no stream');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let text = '';
let cancelled = false;
const cancel = async (): Promise<void> => {
if (cancelled) return;
cancelled = true;
await reader.cancel().catch(() => undefined);
};
const onAbort = () => { void cancel(); };
signal.addEventListener('abort', onAbort, { once: true });
let sources: DictionarySource[] = [];
let sawDone = false;
let worker: DictionaryWorker | undefined;
const consume = (frame: string): void => {
const event = parseEvent(frame);
if (!event) return;
if (!worker && event.type === 'done') {
throw new Error('Dictionary stream missing worker event');
}
if (!worker && event.type !== 'worker') {
throw new Error('Dictionary worker event must be first');
}
if (worker && event.type === 'worker') {
throw new Error('Dictionary worker event must be first');
}
emit(event);
if (event.type === 'delta') text += event.data.text;
if (event.type === 'sources') sources = event.data.items;
if (event.type === 'worker') worker = event.data;
if (event.type === 'done') sawDone = true;
if (event.type === 'error') throw new Error(event.data.message);
};
try {
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
buffer = buffer
.replace(/\r\n/g, '\n')
.replace(done ? /\r/g : /\r(?!$)/g, '\n');
let boundary = buffer.indexOf('\n\n');
while (boundary >= 0) {
consume(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf('\n\n');
}
if (done) break;
}
if (buffer.trim()) consume(buffer);
if (!sawDone) throw new Error('Dictionary stream ended before done');
if (!worker) throw new Error('Dictionary stream missing worker event');
return { text, sources, worker };
} catch (error) {
await cancel();
throw error;
} finally {
signal.removeEventListener('abort', onAbort);
reader.releaseLock();
}
}
async function responseError(response: Response): Promise<DictionaryHttpError> {
const body = await response.json().catch(() => null) as unknown;
const candidate = body && typeof body === 'object' && !Array.isArray(body)
? (body as { error?: unknown }).error
: undefined;
const code = typeof candidate === 'string'
&& KNOWN_DICTIONARY_HTTP_ERROR_CODES.has(candidate as DictionaryHttpErrorCode)
? candidate as DictionaryHttpErrorCode
: 'dictionary_unknown_error';
return new DictionaryHttpError(code, response.status);
}
async function fetchLookup(
request: DictionaryLookupRequest,
controller: AbortController,
emit: (event: DictionaryLookupEvent) => void,
): Promise<DictionaryLookupResult> {
const response = await fetch('/api/dictionary/lookup', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
signal: controller.signal,
});
if (!response.ok) throw await responseError(response);
try {
return await readSse(response, emit, controller.signal);
} catch (error) {
controller.abort();
throw error;
}
}
export async function lookupDictionary(
request: DictionaryLookupRequest,
options: DictionaryLookupOptions = {},
): Promise<DictionaryLookupResult> {
// abort 済みの signal で新規 fetch を開始しない。開始してしまうと subscriber 不在の
// まま HTTP/LLM リクエストが走り、サーバーの同時実行枠とレート枠を無駄に消費する。
if (options.signal?.aborted) throw abortError();
const normalized = normalizeDictionarySelectionText(request.text);
if (!normalized.text) throw new Error(`Invalid dictionary text: ${normalized.reason}`);
const normalizedRequest: DictionaryLookupRequest = {
...request,
text: normalized.text,
context: request.context === undefined
? undefined
: normalizeDictionaryContext(request.context),
};
const controller = new AbortController();
const onAbort = () => controller.abort();
options.signal?.addEventListener('abort', onAbort, { once: true });
try {
return await fetchLookup(normalizedRequest, controller, event => options.onEvent?.(event));
} catch (error) {
if (options.signal?.aborted) throw abortError();
throw error;
} finally {
options.signal?.removeEventListener('abort', onAbort);
}
}
+395
View File
@@ -0,0 +1,395 @@
import { describe, expect, it, vi } from 'vitest';
import {
DocumentChatHttpError,
cancelDocumentAnswer,
createDocumentChatSession,
decideDocumentSearch,
deleteDocumentChatSession,
hashMarkdown,
readDocumentChatSse,
sendDocumentQuestion,
} from './documentChat';
const encoder = new TextEncoder();
const streamResponse = (chunks: string[]): Response => new Response(new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
controller.close();
},
}), {
status: 200,
headers: { 'Content-Type': 'text/event-stream; charset=utf-8' },
});
const collect = async <T>(stream: AsyncIterable<T>): Promise<T[]> => {
const result: T[] = [];
for await (const item of stream) result.push(item);
return result;
};
describe('readDocumentChatSse', () => {
it('parses think_delta as a non-terminal event', async () => {
const response = streamResponse([
'event: think_delta\ndata: {"answerId":"a1","text":"reasoning"}\n\n',
'event: done\ndata: {"answerId":"a1","text":"answer"}\n\n',
]);
expect(await collect(readDocumentChatSse(response))).toMatchObject([
{ type: 'think_delta', data: { answerId: 'a1', text: 'reasoning' } },
{ type: 'done', data: { answerId: 'a1', text: 'answer' } },
]);
});
it('parses prompt_progress as a non-terminal event', async () => {
const response = streamResponse([
'event: prompt_progress\ndata: {"answerId":"a1","percent":42}\n\n',
'event: done\ndata: {"answerId":"a1","text":"answer"}\n\n',
]);
expect(await collect(readDocumentChatSse(response))).toMatchObject([
{ type: 'prompt_progress', data: { answerId: 'a1', percent: 42 } },
{ type: 'done', data: { answerId: 'a1', text: 'answer' } },
]);
});
it('parses CRLF and a fragmented terminal search_request', async () => {
const response = streamResponse([
'event: status\r\ndata: {"answerId":"a1","phase":"generating"}\r\n\r\n',
'event: search_',
'request\r\ndata: {"answerId":"a1","requestId":"r1","query":"React 19","expiresAt":42}\r\n\r\n',
]);
expect(await collect(readDocumentChatSse(response))).toEqual([
{ type: 'status', sequence: 1, streamId: expect.any(String), data: { answerId: 'a1', phase: 'generating' } },
{
type: 'search_request',
sequence: 2,
streamId: expect.any(String),
data: { answerId: 'a1', requestId: 'r1', query: 'React 19', expiresAt: 42 },
},
]);
});
it('accepts done and error as terminals and preserves error codes and sources', async () => {
const done = streamResponse([
'event: sources\ndata: {"answerId":"a1","items":[{"number":1,"title":"T","url":"https://example.com","snippet":"S"}]}\n\n',
'event: done\ndata: {"answerId":"a1","text":"answer"}\n\n',
]);
expect(await collect(readDocumentChatSse(done))).toMatchObject([
{ type: 'sources', data: { items: [{ number: 1, url: 'https://example.com' }] } },
{ type: 'done', data: { text: 'answer' } },
]);
const failed = streamResponse([
'event: error\ndata: {"answerId":"a2","code":"llm_error","message":"safe"}\n\n',
]);
expect(await collect(readDocumentChatSse(failed))).toEqual([
{
type: 'error',
streamId: expect.any(String),
sequence: 1,
data: { answerId: 'a2', code: 'llm_error', message: 'safe' },
},
]);
});
it('accepts the search-failure continuation status', async () => {
const response = streamResponse([
'event: status\ndata: {"answerId":"a1","phase":"search_failed_continuing"}\n\n',
'event: done\ndata: {"answerId":"a1","text":"document-only answer"}\n\n',
]);
expect(await collect(readDocumentChatSse(response))).toMatchObject([
{ type: 'status', data: { answerId: 'a1', phase: 'search_failed_continuing' } },
{ type: 'done', data: { answerId: 'a1', text: 'document-only answer' } },
]);
});
it('ignores comments, malformed JSON, unknown events, and invalid known event data', async () => {
const response = streamResponse([
': heartbeat\n\n',
'event: future\ndata: {"answerId":"a1"}\n\n',
'event: delta\ndata: not-json\n\n',
'event: delta\ndata: {"answerId":"a1","text":7}\n\n',
'event: delta\ndata: {"answerId":"a1","text":"ok"}\n\n',
'event: done\ndata: {"answerId":"a1","text":"ok"}\n\n',
]);
expect(await collect(readDocumentChatSse(response))).toMatchObject([
{ type: 'delta', sequence: 1, data: { text: 'ok' } },
{ type: 'done', sequence: 2, data: { text: 'ok' } },
]);
});
it('rejects a stream that closes without a terminal event', async () => {
const response = streamResponse([
'event: status\ndata: {"answerId":"a1","phase":"queued"}\n\n',
]);
await expect(collect(readDocumentChatSse(response))).rejects.toThrow(
'Document chat stream ended before a terminal event',
);
});
});
describe('document chat HTTP client', () => {
it('hashes exact UTF-8 bytes without normalizing decomposed Unicode', async () => {
expect(await hashMarkdown('Cafe\u0301\n# 文書')).toBe(
'02a0e1cf130c5bf95fc7290dd4a53048b8224a3946f0ae4e573458a5c2603ec9',
);
expect(await hashMarkdown('Cafe\u0301')).not.toBe(await hashMarkdown('Café'));
});
it('uses the Task 5 paths and exact request bodies', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
sessionId: 's1', documentHash: 'h1', worker: { workerId: 'doc-worker', fallback: false },
}), { status: 201, headers: { 'Content-Type': 'application/json' } }))
.mockResolvedValueOnce(streamResponse([
'event: done\ndata: {"answerId":"a1","text":"A"}\n\n',
]))
.mockResolvedValueOnce(streamResponse([
'event: done\ndata: {"answerId":"a1","text":"A2"}\n\n',
]))
.mockResolvedValueOnce(new Response(JSON.stringify({ cancelled: true }), {
status: 200, headers: { 'Content-Type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
const signal = new AbortController().signal;
expect(await createDocumentChatSession(
{ name: 'a.md', markdown: '# A', uiLang: 'ja' },
signal,
)).toEqual({ sessionId: 's1', documentHash: 'h1', worker: { workerId: 'doc-worker', fallback: false } });
await collect(sendDocumentQuestion('s1', {
question: 'Q?', documentHash: 'h1',
}, signal));
await collect(decideDocumentSearch('s1', {
answerId: 'a1', requestId: 'r1', decision: 'allow',
}, signal));
expect(await cancelDocumentAnswer('s1', 'a1', signal)).toEqual({ cancelled: true });
await deleteDocumentChatSession('s1', signal);
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/document-chat/sessions',
'/api/document-chat/sessions/s1/messages',
'/api/document-chat/sessions/s1/search-decisions',
'/api/document-chat/sessions/s1/answers/a1/cancel',
'/api/document-chat/sessions/s1',
]);
expect(JSON.parse(fetchMock.mock.calls[2]![1].body)).toEqual({
answerId: 'a1', requestId: 'r1', decision: 'allow',
});
expect(fetchMock.mock.calls.every(([, init]) => init.credentials === 'same-origin')).toBe(true);
vi.unstubAllGlobals();
});
it.each([409, 410, 401])('throws a typed HTTP error for status %s', async status => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
error: status === 401 ? 'authentication_required' : 'search_request_expired',
message: 'safe',
}), { status, headers: { 'Content-Type': 'application/json' } })));
try {
await collect(sendDocumentQuestion('s1', { question: 'Q', documentHash: 'h' }));
throw new Error('expected rejection');
} catch (error) {
expect(error).toBeInstanceOf(DocumentChatHttpError);
expect(error).toMatchObject({
status,
code: status === 401 ? 'authentication_required' : 'search_request_expired',
message: 'safe',
});
} finally {
vi.unstubAllGlobals();
}
});
it('passes AbortSignal through fetch and preserves AbortError', async () => {
const controller = new AbortController();
const fetchMock = vi.fn((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(
new DOMException('The operation was aborted', 'AbortError'),
), { once: true });
}));
vi.stubGlobal('fetch', fetchMock);
const request = createDocumentChatSession({
name: 'a.md', markdown: '# A', uiLang: 'en',
}, controller.signal);
controller.abort();
await expect(request).rejects.toMatchObject({ name: 'AbortError' });
expect(fetchMock.mock.calls[0]![1].signal).toBe(controller.signal);
vi.unstubAllGlobals();
});
});
describe('document chat safety boundaries', () => {
it('cancels the reader after a terminal frame and rejects an oversized frame', async () => {
const cancel = vi.fn();
const response = {
body: { getReader: () => ({
read: vi.fn().mockResolvedValueOnce({
done: false,
value: encoder.encode('event: done\ndata: {"answerId":"a1","text":"ok"}\n\nunread'),
}),
cancel,
}) },
} as unknown as Response;
await collect(readDocumentChatSse(response));
expect(cancel).toHaveBeenCalledOnce();
await expect(collect(readDocumentChatSse(streamResponse([
`event: delta\ndata: {"answerId":"a1","text":"${'x'.repeat(1_048_576)}"}\n\n`,
])))).rejects.toThrow('document_chat_sse_frame_too_large');
});
it('cancels an in-progress read when aborted', async () => {
const controller = new AbortController();
const cancel = vi.fn();
const response = {
body: { getReader: () => ({ read: () => new Promise(() => undefined), cancel }) },
} as unknown as Response;
const reading = collect(readDocumentChatSse(response, { signal: controller.signal }));
controller.abort();
await expect(reading).rejects.toMatchObject({ name: 'AbortError' });
expect(cancel).toHaveBeenCalledOnce();
});
it('rejects malformed success JSON and oversized error JSON', async () => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(new Response('{bad', {
status: 201, headers: { 'Content-Type': 'application/json' },
}))
.mockResolvedValueOnce(new Response('x'.repeat(65_537), {
status: 409, headers: { 'Content-Type': 'application/json' },
})));
await expect(createDocumentChatSession({ name: 'a', markdown: 'a', uiLang: 'ja' }))
.rejects.toMatchObject({ code: 'document_chat_invalid_response' });
await expect(createDocumentChatSession({ name: 'a', markdown: 'a', uiLang: 'ja' }))
.rejects.toMatchObject({ code: 'document_chat_error_body_too_large' });
vi.unstubAllGlobals();
});
it('fails explicitly when Web Crypto is unavailable', async () => {
vi.stubGlobal('crypto', undefined);
await expect(hashMarkdown('# A')).rejects.toMatchObject({
code: 'document_chat_crypto_unsupported',
});
vi.unstubAllGlobals();
});
});
describe('document chat schema hardening', () => {
it('assigns a unique stream id per invocation and drops unsafe sources', async () => {
const frame = 'event: sources\ndata: {"answerId":"a1","items":['
+ '{"number":1,"title":"Safe","url":"https://example.com/a","snippet":"ok"},'
+ '{"number":2,"title":"Bad","url":"https://user:[email protected]","snippet":"bad"},'
+ '{"number":3,"title":"Bad","url":"javascript:alert(1)","snippet":"bad"}'
+ ']}\n\nevent: done\ndata: {"answerId":"a1","text":"done"}\n\n';
const first = await collect(readDocumentChatSse(streamResponse([frame])));
const second = await collect(readDocumentChatSse(streamResponse([frame])));
expect(first[0]!.streamId).not.toBe(second[0]!.streamId);
expect(first[0]).toMatchObject({
type: 'sources', data: { items: [{ number: 1, url: 'https://example.com/a' }] },
});
});
it('rejects malformed create and cancel success shapes', async () => {
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ sessionId: 1, documentHash: 'h' }), {
status: 201, headers: { 'Content-Type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({ cancelled: false }), {
status: 200, headers: { 'Content-Type': 'application/json' },
})));
await expect(createDocumentChatSession({ name: 'a', markdown: 'a', uiLang: 'ja' }))
.rejects.toMatchObject({ code: 'document_chat_invalid_response' });
await expect(cancelDocumentAnswer('s', 'a'))
.rejects.toMatchObject({ code: 'document_chat_invalid_response' });
vi.unstubAllGlobals();
});
});
describe('document chat incremental parser complexity', () => {
it('does not re-encode accumulated text and preserves enumerable stream identity', async () => {
const OriginalTextEncoder = TextEncoder;
let constructions = 0;
class CountingTextEncoder extends OriginalTextEncoder {
constructor() { super(); constructions += 1; }
}
vi.stubGlobal('TextEncoder', CountingTextEncoder);
const terminal = 'event: done\ndata: {"answerId":"a1","text":"ok"}\n\n';
const bytes = new OriginalTextEncoder().encode(`${':\n\n'.repeat(349_500)}${terminal}`);
let offset = 0;
const cancel = vi.fn();
const response = {
body: { getReader: () => ({
read: () => offset < bytes.length
? Promise.resolve({ done: false, value: bytes.subarray(offset, ++offset) })
: Promise.resolve({ done: true, value: undefined }),
cancel,
}) },
} as unknown as Response;
const [event] = await collect(readDocumentChatSse(response));
expect(constructions).toBe(0);
expect(Object.keys(event!)).toContain('streamId');
expect(structuredClone(event!)).toMatchObject({ streamId: event!.streamId, sequence: 1 });
vi.unstubAllGlobals();
});
it('cancels the reader when its consumer breaks early', async () => {
const cancel = vi.fn();
const reader = {
read: vi.fn().mockResolvedValueOnce({
done: false,
value: new TextEncoder().encode(
'event: status\ndata: {"answerId":"a1","phase":"queued"}\n\n',
),
}),
cancel,
};
const response = { body: { getReader: () => reader } } as unknown as Response;
for await (const _event of readDocumentChatSse(response)) break;
expect(cancel).toHaveBeenCalledOnce();
});
it('requires exact JSON and SSE media types before parameters', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('{}', {
status: 201, headers: { 'Content-Type': 'application/problem+json' },
})));
await expect(createDocumentChatSession({ name: 'a', markdown: 'a', uiLang: 'ja' }))
.rejects.toMatchObject({ code: 'document_chat_invalid_content_type' });
vi.unstubAllGlobals();
});
});
describe('document chat terminal scan boundary', () => {
it.each([
['done', '{"answerId":"a1","text":"ok"}'],
['search_request', '{"answerId":"a1","requestId":"r1","query":"q","expiresAt":42}'],
['error', '{"answerId":"a1","code":"llm_error"}'],
])('stops scanning the current chunk immediately after %s', async (type, data) => {
const cancel = vi.fn();
const bytes = encoder.encode(`event: ${type}\ndata: ${data}\n\n${'x'.repeat(1_048_577)}`);
const reader = {
read: vi.fn().mockResolvedValueOnce({ done: false, value: bytes }),
cancel,
};
const response = { body: { getReader: () => reader } } as unknown as Response;
const result = await collect(readDocumentChatSse(response));
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ type });
expect(cancel).toHaveBeenCalledOnce();
});
});
describe("document chat worker metadata contract", () => {
it("maps a legacy 404 to server unsupported", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: "not_found" }), { status: 404, headers: { "Content-Type": "application/json" } })));
await expect(createDocumentChatSession({ name: "a", markdown: "# A", uiLang: "ja" })).rejects.toMatchObject({ status: 404, code: "document_chat_server_unsupported" });
vi.unstubAllGlobals();
});
it.each([undefined, null, {}, { workerId: "", fallback: false }, { workerId: "x".repeat(129), fallback: false }, { workerId: "ok", fallback: "false" }])("rejects malformed worker metadata %j", async worker => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ sessionId: "s", documentHash: "h", worker }), { status: 201, headers: { "Content-Type": "application/json" } })));
await expect(createDocumentChatSession({ name: "a", markdown: "# A", uiLang: "ja" })).rejects.toMatchObject({ code: "document_chat_invalid_response" });
vi.unstubAllGlobals();
});
});
+250
View File
@@ -0,0 +1,250 @@
export type DocumentChatUiLang = 'ja' | 'en';
export type SearchDecision = 'allow' | 'deny' | 'continue_without_search';
export type DocumentChatStreamMode = 'initial' | 'resume';
export interface AnswerWorkerMetadata { workerId: string; fallback: boolean }
export interface SessionRef { sessionId: string; documentHash: string; worker: AnswerWorkerMetadata }
export interface DocumentChatSource { number: number; title: string; url: string; snippet: string }
export type DocumentChatEvent =
| { type: 'status'; data: { answerId: string; phase: 'queued' | 'generating' | 'searching' | 'search_failed_continuing' } }
| { type: 'prompt_progress'; data: { answerId: string; percent: number } }
| { type: 'think_delta'; data: { answerId: string; text: string } }
| { type: 'delta'; data: { answerId: string; text: string } }
| { type: 'search_request'; data: { answerId: string; requestId: string; query: string; expiresAt: number } }
| { type: 'sources'; data: { answerId: string; items: DocumentChatSource[] } }
| { type: 'done'; data: { answerId: string; text: string } }
| { type: 'error'; data: { answerId: string; code: string; message?: string } };
export type SequencedDocumentChatEvent = DocumentChatEvent & { readonly streamId: string; sequence: number };
export class DocumentChatHttpError extends Error {
constructor(public readonly status: number, public readonly code: string, message: string) {
super(message); this.name = 'DocumentChatHttpError';
}
}
export class DocumentChatClientError extends Error {
constructor(public readonly code: string, message = code) { super(message); this.name = 'DocumentChatClientError'; }
}
export const DOCUMENT_CHAT_SSE_TOTAL_BYTES = 10 * 1024 * 1024;
export const DOCUMENT_CHAT_SSE_FRAME_BYTES = 1024 * 1024;
export const DOCUMENT_CHAT_SSE_DATA_BYTES = 768 * 1024;
const JSON_BODY_BYTES = 64 * 1024;
const SOURCE_TITLE_CHARS = 500;
const SOURCE_URL_CHARS = 2_048;
const SOURCE_SNIPPET_CHARS = 2_000;
let streamCounter = 0;
const nextStreamId = (): string => `document-chat-stream-${++streamCounter}`;
const abortError = (): DOMException => new DOMException('The operation was aborted', 'AbortError');
const isObject = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const isString = (value: unknown): value is string => typeof value === 'string';
const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
const parseWorker = (value: unknown): AnswerWorkerMetadata | null => {
if (!isObject(value) || !isString(value.workerId) || !value.workerId || value.workerId.length > 128 || typeof value.fallback !== 'boolean') return null;
return { workerId: value.workerId, fallback: value.fallback };
};
const parseSource = (value: unknown): DocumentChatSource | null => {
if (!isObject(value) || !isNumber(value.number) || !Number.isInteger(value.number)
|| !isString(value.title) || value.title.length > SOURCE_TITLE_CHARS
|| !isString(value.url) || value.url.length > SOURCE_URL_CHARS
|| !isString(value.snippet) || value.snippet.length > SOURCE_SNIPPET_CHARS) return null;
try {
const url = new URL(value.url);
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) return null;
} catch { return null; }
return { number: value.number, title: value.title, url: value.url, snippet: value.snippet };
};
const parseEvent = (eventName: string, rawData: string): DocumentChatEvent | null => {
if (!eventName || !rawData) return null;
let data: unknown;
try { data = JSON.parse(rawData); } catch { return null; }
if (!isObject(data) || !isString(data.answerId)) return null;
if (eventName === 'status' && (data.phase === 'queued' || data.phase === 'generating' || data.phase === 'searching' || data.phase === 'search_failed_continuing')) return { type: 'status', data: { answerId: data.answerId, phase: data.phase } };
if (eventName === 'prompt_progress' && isNumber(data.percent)) return { type: 'prompt_progress', data: { answerId: data.answerId, percent: data.percent } };
if (eventName === 'think_delta' && isString(data.text)) return { type: 'think_delta', data: { answerId: data.answerId, text: data.text } };
if (eventName === 'delta' && isString(data.text)) return { type: 'delta', data: { answerId: data.answerId, text: data.text } };
if (eventName === 'search_request' && isString(data.requestId) && isString(data.query) && isNumber(data.expiresAt)) return { type: 'search_request', data: { answerId: data.answerId, requestId: data.requestId, query: data.query, expiresAt: data.expiresAt } };
if (eventName === 'sources' && Array.isArray(data.items)) {
const items = data.items.map(parseSource).filter((item): item is DocumentChatSource => item !== null);
return { type: 'sources', data: { answerId: data.answerId, items: items.map(item => ({ ...item })) } };
}
if (eventName === 'done' && isString(data.text)) return { type: 'done', data: { answerId: data.answerId, text: data.text } };
if (eventName === 'error' && isString(data.code) && (data.message === undefined || isString(data.message))) return { type: 'error', data: { answerId: data.answerId, code: data.code, message: isString(data.message) ? data.message : undefined } };
return null;
};
const TERMINALS = new Set<DocumentChatEvent['type']>(['search_request', 'done', 'error']);
type SseReadOptions = { signal?: AbortSignal; streamId?: string };
export const readDocumentChatSse = async function* (response: Response, options: SseReadOptions = {}): AsyncGenerator<SequencedDocumentChatEvent> {
if (!response.body) throw new DocumentChatClientError('document_chat_stream_missing');
const reader = response.body.getReader();
let readerCancelled = false;
const cancelReader = async (): Promise<void> => {
if (readerCancelled) return;
readerCancelled = true;
await Promise.resolve(reader.cancel()).catch(() => undefined);
};
const decoder = new TextDecoder();
const streamId = options.streamId ?? nextStreamId();
const lineBuffer = new Uint8Array(DOCUMENT_CHAT_SSE_FRAME_BYTES + 1);
let lineBytes = 0;
let frameBytes = 0;
let totalBytes = 0;
let dataBytes = 0;
let eventName = '';
let dataLines: string[] = [];
let sequence = 0;
let terminal = false;
let skipLfAfterCr = false;
const finishEvent = (): DocumentChatEvent | null => {
const event = parseEvent(eventName, dataLines.join('\n'));
eventName = '';
dataLines = [];
dataBytes = 0;
frameBytes = 0;
return event;
};
const finishLine = (): DocumentChatEvent | null => {
if (lineBytes === 0) return finishEvent();
const bytes = lineBuffer.subarray(0, lineBytes);
const line = decoder.decode(bytes);
if (line.startsWith('event:')) eventName = line.slice(6).trim();
if (line.startsWith('data:')) {
const payloadStart = lineBytes > 5 && bytes[5] === 0x20 ? 6 : 5;
dataBytes += lineBytes - payloadStart + (dataLines.length ? 1 : 0);
if (dataBytes > DOCUMENT_CHAT_SSE_DATA_BYTES) throw new DocumentChatClientError('document_chat_sse_data_too_large');
dataLines.push(line.slice(payloadStart));
}
lineBytes = 0;
return null;
};
const read = async (): Promise<ReadableStreamReadResult<Uint8Array>> => {
if (options.signal?.aborted) throw abortError();
if (!options.signal) return reader.read();
return new Promise((resolve, reject) => {
const onAbort = () => { void cancelReader(); reject(abortError()); };
options.signal!.addEventListener('abort', onAbort, { once: true });
reader.read().then(resolve, reject).finally(() => options.signal!.removeEventListener('abort', onAbort));
});
};
try {
while (!terminal) {
const { done, value } = await read();
const ready: DocumentChatEvent[] = [];
if (value) {
let consumedBytes = 0;
for (let index = 0; index < value.byteLength; index += 1) {
const byte = value[index]!;
consumedBytes = index + 1;
if (totalBytes + consumedBytes > DOCUMENT_CHAT_SSE_TOTAL_BYTES) throw new DocumentChatClientError('document_chat_sse_total_too_large');
if (skipLfAfterCr && byte === 0x0a) { skipLfAfterCr = false; continue; }
skipLfAfterCr = false;
frameBytes += 1;
if (frameBytes > DOCUMENT_CHAT_SSE_FRAME_BYTES) throw new DocumentChatClientError('document_chat_sse_frame_too_large');
if (byte === 0x0d || byte === 0x0a) {
const event = finishLine();
if (event) {
ready.push(event);
if (TERMINALS.has(event.type)) { terminal = true; break; }
}
if (byte === 0x0d) skipLfAfterCr = true;
continue;
}
lineBuffer[lineBytes] = byte;
lineBytes += 1;
if (lineBytes > DOCUMENT_CHAT_SSE_FRAME_BYTES) throw new DocumentChatClientError('document_chat_sse_frame_too_large');
}
totalBytes += consumedBytes;
}
if (done) {
if (lineBytes > 0) {
const event = finishLine();
if (event) ready.push(event);
}
if (eventName || dataLines.length) {
const event = finishEvent();
if (event) ready.push(event);
}
}
for (const event of ready) {
sequence += 1;
const sequenced: SequencedDocumentChatEvent = { ...event, streamId, sequence };
yield sequenced;
if (TERMINALS.has(event.type)) { terminal = true; break; }
}
if (done) break;
}
if (!terminal) throw new DocumentChatClientError('document_chat_stream_incomplete', 'Document chat stream ended before a terminal event');
} finally { await cancelReader(); }
};
const mediaType = (response: Response): string => (response.headers.get('content-type') ?? '').split(';', 1)[0]!.trim().toLowerCase();
const contentTypeIsJson = (response: Response): boolean => mediaType(response) === 'application/json';
const readBoundedText = async (response: Response, limit: number, code: string): Promise<string> => {
if (!response.body) return '';
const reader = response.body.getReader(); let total = 0; const chunks: Uint8Array[] = [];
let readerCancelled = false;
const cancelReader = async (): Promise<void> => {
if (readerCancelled) return;
readerCancelled = true;
await Promise.resolve(reader.cancel()).catch(() => undefined);
};
try {
while (true) { const { done, value } = await reader.read(); if (done) break; if (!value) continue; total += value.byteLength; if (total > limit) throw new DocumentChatClientError(code); chunks.push(value); }
} finally { await cancelReader(); }
const merged = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.byteLength; }
return new TextDecoder().decode(merged);
};
const parseJsonResponse = async (response: Response): Promise<unknown> => {
if (!contentTypeIsJson(response)) throw new DocumentChatHttpError(response.status, 'document_chat_invalid_content_type', 'Invalid document chat response');
let text: string;
try {
text = await readBoundedText(response, JSON_BODY_BYTES, response.ok ? 'document_chat_response_too_large' : 'document_chat_error_body_too_large');
} catch (error) {
if (error instanceof DocumentChatClientError) throw new DocumentChatHttpError(response.status, error.code, error.message);
throw error;
}
try { return JSON.parse(text); } catch { throw new DocumentChatHttpError(response.status, 'document_chat_invalid_response', 'Invalid document chat response'); }
};
const httpError = async (response: Response): Promise<never> => {
let body: unknown;
try { body = await parseJsonResponse(response); }
catch (error) {
if (error instanceof DocumentChatClientError) throw new DocumentChatHttpError(response.status, error.code, error.message);
throw error;
}
const value = isObject(body) ? body : {};
throw new DocumentChatHttpError(response.status, isString(value.error) ? value.error : 'document_chat_http_error', isString(value.message) ? value.message : `Document chat request failed (${response.status})`);
};
const requestHeaders = { 'Content-Type': 'application/json' };
const fetchResponse = (path: string, init: RequestInit): Promise<Response> => fetch(path, { credentials: 'same-origin', ...init });
const encodePath = (value: string): string => encodeURIComponent(value);
export const hashMarkdown = async (markdown: string): Promise<string> => {
if (!globalThis.crypto?.subtle) throw new DocumentChatClientError('document_chat_crypto_unsupported');
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(markdown));
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
};
export const createDocumentChatSession = async (input: { name: string; markdown: string; uiLang: DocumentChatUiLang }, signal?: AbortSignal): Promise<SessionRef> => {
const response = await fetchResponse('/api/document-chat/sessions', { method: 'POST', headers: requestHeaders, body: JSON.stringify(input), signal });
if (!response.ok) {
if (response.status === 404) throw new DocumentChatHttpError(404, 'document_chat_server_unsupported', 'Document chat is not supported by this server');
return httpError(response);
}
const body = await parseJsonResponse(response);
const worker = isObject(body) ? parseWorker(body.worker) : null;
if (!isObject(body) || !isString(body.sessionId) || !isString(body.documentHash) || !worker) throw new DocumentChatHttpError(response.status, 'document_chat_invalid_response', 'Invalid document chat response');
return { sessionId: body.sessionId, documentHash: body.documentHash, worker };
};
const fetchStream = (path: string, body: unknown, signal?: AbortSignal): AsyncGenerator<SequencedDocumentChatEvent> => {
const streamId = nextStreamId();
return (async function* () { const response = await fetchResponse(path, { method: 'POST', headers: requestHeaders, body: JSON.stringify(body), signal }); if (!response.ok) return httpError(response); if (mediaType(response) !== 'text/event-stream') throw new DocumentChatHttpError(response.status, 'document_chat_invalid_content_type', 'Invalid document chat stream'); yield* readDocumentChatSse(response, { signal, streamId }); })();
};
export const sendDocumentQuestion = (sessionId: string, input: { question: string; documentHash: string }, signal?: AbortSignal) => fetchStream(`/api/document-chat/sessions/${encodePath(sessionId)}/messages`, input, signal);
export const decideDocumentSearch = (sessionId: string, input: { answerId: string; requestId: string; decision: SearchDecision }, signal?: AbortSignal) => fetchStream(`/api/document-chat/sessions/${encodePath(sessionId)}/search-decisions`, input, signal);
export const cancelDocumentAnswer = async (sessionId: string, answerId: string, signal?: AbortSignal): Promise<{ cancelled: true }> => {
const response = await fetchResponse(`/api/document-chat/sessions/${encodePath(sessionId)}/answers/${encodePath(answerId)}/cancel`, { method: 'POST', headers: requestHeaders, signal }); if (!response.ok) return httpError(response); const body = await parseJsonResponse(response); if (!isObject(body) || body.cancelled !== true) throw new DocumentChatHttpError(response.status, 'document_chat_invalid_response', 'Invalid document chat response'); return { cancelled: true };
};
export const deleteDocumentChatSession = async (sessionId: string, signal?: AbortSignal): Promise<void> => { const response = await fetchResponse(`/api/document-chat/sessions/${encodePath(sessionId)}`, { method: 'DELETE', signal }); if (!response.ok) return httpError(response); const text = await readBoundedText(response, JSON_BODY_BYTES, 'document_chat_response_too_large'); if (text.trim()) throw new DocumentChatHttpError(response.status, 'document_chat_invalid_response', 'Invalid document chat response'); };
+45
View File
@@ -0,0 +1,45 @@
/** Read-only histories for delegate runs and spawned jobs. */
export type ExecutionKind = 'delegate' | 'subtask';
export interface TaskExecution {
id: string;
kind: ExecutionKind;
jobId: string;
rootJobId: string;
runId?: string | null;
parentRunId?: string | null;
title: string;
filesChanged?: string[];
sharedHistory?: boolean;
status: string;
createdAt: string;
}
export interface ExecutionList { executions: TaskExecution[]; truncated: boolean; active?: boolean }
export interface ExecutionMessage {
role: 'user' | 'assistant' | 'tool';
content: string;
thinking?: string;
timestamp?: string;
isError?: boolean;
durationMs?: number;
cacheHit?: boolean;
llm?: { durationMs: number; promptTokens?: number; completionTokens?: number; hadError?: boolean };
toolCalls?: Array<{ id: string; name: string; arguments: string }>;
toolCallId?: string;
}
export interface ExecutionConversation {
messages: ExecutionMessage[];
activityLog?: string;
source: 'history' | 'transcript' | 'summary' | 'unavailable';
truncated: boolean;
}
async function read<T>(url: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`Execution history: ${response.status}`);
return response.json();
}
export function fetchExecutions(taskId: number, signal?: AbortSignal): Promise<ExecutionList> {
return read(`/api/local/tasks/${taskId}/executions`, signal);
}
export function fetchExecutionConversation(taskId: number, executionId: string, signal?: AbortSignal): Promise<ExecutionConversation> {
return read(`/api/local/tasks/${taskId}/executions/${encodeURIComponent(executionId)}/conversation`, signal);
}
+6 -2
View File
@@ -9,11 +9,15 @@ export interface FolderFileEntry { name: string; size: number; mtime: string }
* listing to that space's folder (`data/spaces/{id}/{subdir}`); omitting it
* targets the per-user folder. Matches the convention used by the piece/agents
* space-scoped fetchers above.
*
* truncated は、並べた項目のうち「在るのに読めなかった」ものを飛ばしたときに立つ
* (#1320)。消えたもの(リンク切れ・並べた直後に消えたファイル)では立たない。
* 意味はタスク・スペースの一覧の口の truncated と同じ。画面の知らせはまだ足していない。
*/
export async function listFolderFiles(subdir: 'browser-macros' | 'recordings', spaceId?: string): Promise<FolderFileEntry[]> {
export async function listFolderFiles(subdir: 'browser-macros' | 'recordings', spaceId?: string): Promise<{ files: FolderFileEntry[]; truncated: boolean }> {
const r = await fetch(`${BASE}/users/me/folder/list${buildQuery({ subdir, spaceId })}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`listFolderFiles: ${r.status}`);
return (await r.json() as { files: FolderFileEntry[] }).files;
return await r.json() as { files: FolderFileEntry[]; truncated: boolean };
}
/** Read one folder file's text content (space-scoped when `spaceId` is given). */
+138
View File
@@ -0,0 +1,138 @@
// issue #1558 段 2: LLM へ送った本文と応答の控えを読む口。
//
// 一覧はページ単位で、控えの末尾から必要なぶんだけ読む。1 件の本文は開いた
// ときだけ取りに行く(一覧に本文は載らない)。
//
// 絞り込み(時刻・用途・ステップ)はサーバへ渡す(issue #1628)。画面で
// 読み込んだぶんを間引くのではなく、条件に合う行を控えから探してもらう。
// 全文検索は別の口(`/search`)で、送った本文・応答・道具の全文が対象。
import { BASE } from './client';
export interface LlmCallRowResponseDTO {
ts: number;
attempts: number;
durationMs?: number;
model?: string;
promptTokens?: number;
completionTokens?: number;
thinkingChars?: number;
textChars: number;
toolNames: string[];
hadError?: boolean;
errorClass?: string;
errorMessage?: string;
httpStatus?: number;
truncated?: boolean;
}
export interface LlmCallRowDTO {
id: string;
/** 控えの置き場からの相対パス。詳細を引くときにそのまま返す。 */
file: string;
callId: string;
attempt: number;
maxAttempts?: number;
purpose: string;
ts: number;
mode: 'full' | 'delta';
bytes: number;
jobId?: string;
delegateRunId?: string;
movement?: string;
iteration?: number;
contextMode?: 'simple' | 'compact';
thinkingRewound: boolean;
hasReasoning: boolean;
truncated?: boolean;
response?: LlmCallRowResponseDTO;
}
export interface LlmCallsPageDTO {
rows: LlmCallRowDTO[];
cursor: string | null;
}
/** 一覧の絞り込み。時刻は ISO 文字列。空の値は付けない。 */
export interface LlmCallsFilterDTO {
since?: string;
until?: string;
purpose?: string;
movement?: string;
}
/** 記録に現れる用途とステップ。選択肢はここから作る(読み込んだ行からは作らない)。 */
export interface LlmCallFacetsDTO {
purposes: string[];
movements: string[];
}
export type LlmCallHitDTO = 'request' | 'response' | 'tool';
export interface LlmCallSearchRowDTO extends LlmCallRowDTO {
hits: LlmCallHitDTO[];
}
export interface LlmCallSearchResultDTO {
rows: LlmCallSearchRowDTO[];
/** 上限(新しい側から 200 件)を超えて切り捨てた。 */
truncated: boolean;
}
export type LlmCallRestoreDTO =
| { state: 'ok'; messages: unknown[]; settings: Record<string, unknown> }
| { state: 'unavailable'; reason: string };
export interface LlmCallDetailDTO {
request: LlmCallRowDTO;
restore: LlmCallRestoreDTO;
delta: { base: { callId: string; attempt: number; count: number } | null; appended: unknown[] } | null;
response: (LlmCallRowResponseDTO & {
text: string;
reasoning?: string;
toolCalls: Array<{ id: string; name: string; arguments: string }>;
}) | null;
}
export async function fetchLlmCallsPage(
taskId: number,
cursor?: string,
limit = 30,
filter: LlmCallsFilterDTO = {},
): Promise<LlmCallsPageDTO> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set('cursor', cursor);
if (filter.since) params.set('since', filter.since);
if (filter.until) params.set('until', filter.until);
if (filter.purpose) params.set('purpose', filter.purpose);
if (filter.movement) params.set('movement', filter.movement);
const response = await fetch(`${BASE}/local/tasks/${taskId}/llm-calls?${params}`, { credentials: 'include' });
const body = await response.json().catch(() => ({})) as LlmCallsPageDTO & { error?: string };
if (!response.ok) throw new Error(body.error ?? 'failed_to_fetch_llm_calls');
return { rows: body.rows ?? [], cursor: body.cursor ?? null };
}
export async function fetchLlmCallDetail(
taskId: number,
row: Pick<LlmCallRowDTO, 'file' | 'callId' | 'attempt'>,
): Promise<LlmCallDetailDTO> {
const params = new URLSearchParams({ file: row.file, callId: row.callId, attempt: String(row.attempt) });
const response = await fetch(`${BASE}/local/tasks/${taskId}/llm-calls/detail?${params}`, { credentials: 'include' });
const body = await response.json().catch(() => ({})) as LlmCallDetailDTO & { error?: string };
if (!response.ok) throw new Error(body.error ?? 'failed_to_fetch_llm_call');
return body;
}
export async function fetchLlmCallFacets(taskId: number): Promise<LlmCallFacetsDTO> {
const response = await fetch(`${BASE}/local/tasks/${taskId}/llm-calls/facets`, { credentials: 'include' });
const body = await response.json().catch(() => ({})) as Partial<LlmCallFacetsDTO> & { error?: string };
if (!response.ok) throw new Error(body.error ?? 'failed_to_fetch_llm_call_facets');
return { purposes: body.purposes ?? [], movements: body.movements ?? [] };
}
export async function searchLlmCalls(taskId: number, q: string): Promise<LlmCallSearchResultDTO> {
const params = new URLSearchParams({ q });
const response = await fetch(`${BASE}/local/tasks/${taskId}/llm-calls/search?${params}`, { credentials: 'include' });
const body = await response.json().catch(() => ({})) as Partial<LlmCallSearchResultDTO> & { error?: string };
if (!response.ok) throw new Error(body.error ?? 'failed_to_search_llm_calls');
return { rows: body.rows ?? [], truncated: body.truncated === true };
}
+21
View File
@@ -0,0 +1,21 @@
// Per-call LLM metrics for one task's Trace tab charts (design §5 / Phase 3).
import { BASE } from './client';
import type { LlmMetricsResponse } from '../lib/llmMetrics';
export async function fetchTaskLlmMetrics(taskId: number): Promise<LlmMetricsResponse> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/llm-metrics`);
if (!res.ok) throw new Error(`fetchTaskLlmMetrics failed: ${res.status}`);
const body = await res.json();
const rows = body.rows ?? [];
return {
rows,
subtaskJobIds: body.subtaskJobIds ?? [],
subtaskParents: body.subtaskParents ?? {},
settledJobIds: body.settledJobIds ?? [],
unknownJobIds: body.unknownJobIds ?? [],
// Fall back to the row count rather than 0: an older server that does not
// send a total must not make the panel claim the task has no calls.
totalCount: typeof body.totalCount === 'number' ? body.totalCount : rows.length,
truncated: body.truncated === true,
};
}
+133
View File
@@ -1,5 +1,6 @@
// api.ts から分割(挙動不変): 通知 V2(Web Push)。
import { BASE } from './client';
import { resolveTimeZoneId } from './calendar';
// ── Notifications V2 (Web Push) ───────────────────────────────────────
@@ -35,6 +36,15 @@ export interface VapidPublicKeyDTO {
keyId: string;
}
export interface NotificationCapabilitiesDTO {
eventPipelineEnabled: boolean;
}
export async function fetchNotificationCapabilities(): Promise<NotificationCapabilitiesDTO> {
const res = await fetch(`${BASE}/notifications/capabilities`);
return notificationsJsonOrThrow(res, 'failed to fetch notification capabilities');
}
async function notificationsJsonOrThrow<T>(res: Response, fallback: string): Promise<T> {
const data = await res.json().catch(() => ({} as Record<string, unknown>));
if (!res.ok) throw new Error((data as { error?: string }).error ?? fallback);
@@ -105,3 +115,126 @@ export async function postTestNotification(): Promise<{ ok: boolean }> {
const res = await fetch(`${BASE}/notifications/test`, { method: 'POST' });
return notificationsJsonOrThrow(res, 'failed to send test notification');
}
// ── Notification center (inbox / upcoming) ───────────────────────────
export interface NotificationItemDTO {
id: string;
category: 'succeeded' | 'failed' | 'waiting_human' | 'reminder';
title: string;
summary: string;
occurredAt: string;
readAt: string | null;
spaceName: string | null;
href: string | null;
}
export interface UpcomingReminderDTO {
id: string;
kind: 'calendar' | 'agent';
title: string;
/** 予定の開始(知らせだけのものは知らせの時刻)。UTC の ISO 文字列。 */
startAt: string;
/** 開始時刻を持たない終日の予定。 */
allDay: boolean;
/** 入れた人。エージェントが入れたものは 'agent'。 */
createdBy: 'user' | 'agent';
/** 自分あての知らせだけ解除できる。 */
cancellable: boolean;
/** 時刻の表記を組むのに使うタイムゾーン。 */
timeZone: string;
/** 予定に保存されていたタイムゾーン。持たない予定(=閲覧者の環境で解いた)は null。 */
storedTimeZone: string | null;
spaceName: string | null;
href: string | null;
}
export async function fetchUnreadNotificationCount(): Promise<number> {
const res = await fetch(`${BASE}/notifications/unread-count`);
// サーバは {unreadCount} / {count} のどちらの封筒でも返しうる。
const data = await notificationsJsonOrThrow<{ unreadCount?: number; count?: number }>(
res, 'failed to fetch unread count',
);
return data.unreadCount ?? data.count ?? 0;
}
export async function fetchNotificationInbox(limit: number): Promise<NotificationItemDTO[]> {
const res = await fetch(`${BASE}/notifications/inbox?limit=${limit}`);
const data = await notificationsJsonOrThrow<{ items: NotificationItemDTO[] }>(
res, 'failed to fetch notifications',
);
return data.items;
}
export async function fetchUpcomingReminders(limit: number): Promise<UpcomingReminderDTO[]> {
const params = new URLSearchParams({ limit: String(limit), timeZone: resolveTimeZoneId() });
const res = await fetch(`${BASE}/notifications/upcoming?${params.toString()}`);
const data = await notificationsJsonOrThrow<{ items: UpcomingReminderDTO[] }>(
res, 'failed to fetch upcoming reminders',
);
return data.items;
}
export async function markNotificationRead(
id: string,
): Promise<{ ok: boolean; unreadCount: number }> {
const res = await fetch(`${BASE}/notifications/inbox/${encodeURIComponent(id)}/read`, {
method: 'POST',
});
return notificationsJsonOrThrow(res, 'failed to mark notification read');
}
export async function markAllNotificationsRead(): Promise<{ ok: boolean; unreadCount: number }> {
const res = await fetch(`${BASE}/notifications/inbox/read-all`, { method: 'POST' });
return notificationsJsonOrThrow(res, 'failed to mark all notifications read');
}
export interface NotificationInboxPageDTO {
items: NotificationItemDTO[];
nextCursor: string | null;
}
export interface UpcomingRemindersPageDTO {
items: UpcomingReminderDTO[];
nextCursor: string | null;
}
export async function fetchNotificationInboxPage(input: {
unread?: boolean;
spaceId?: string;
cursor?: string;
limit?: number;
}): Promise<NotificationInboxPageDTO> {
const params = new URLSearchParams();
// server の parseListQuery は 'true' / 'false' のみ受理する
if (input.unread) params.set('unread', 'true');
if (input.spaceId) params.set('spaceId', input.spaceId);
if (input.cursor) params.set('cursor', input.cursor);
if (input.limit) params.set('limit', String(input.limit));
const res = await fetch(`${BASE}/notifications/inbox?${params.toString()}`);
return notificationsJsonOrThrow(res, 'failed to fetch notifications');
}
export async function fetchUpcomingRemindersPage(input: {
cursor?: string;
limit?: number;
}): Promise<UpcomingRemindersPageDTO> {
const params = new URLSearchParams();
if (input.cursor) params.set('cursor', input.cursor);
if (input.limit) params.set('limit', String(input.limit));
// 知らせを付けていない予定は保存タイムゾーンを持たないので、閲覧者の環境のものを渡す。
params.set('timeZone', resolveTimeZoneId());
const res = await fetch(`${BASE}/notifications/upcoming?${params.toString()}`);
return notificationsJsonOrThrow(res, 'failed to fetch upcoming reminders');
}
export async function cancelUpcomingReminder(
kind: UpcomingReminderDTO['kind'],
id: string,
): Promise<{ ok: boolean }> {
const res = await fetch(
`${BASE}/notifications/upcoming/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,
{ method: 'DELETE' },
);
return notificationsJsonOrThrow(res, 'failed to cancel reminder');
}
+18
View File
@@ -0,0 +1,18 @@
export interface OverviewMetrics {
collecting: boolean;
toolCalls: number | null;
compactions: number | null;
activeDurationMs: number | null;
workerId: string | null;
workerName: string | null;
model: string | null;
decodeTps: number | null;
decodeTpsMeasuredAt: string | null;
jobId: string | null;
jobStatus: string | null;
}
export async function fetchOverviewMetrics(taskId: number, signal?: AbortSignal): Promise<{ metrics: OverviewMetrics }> {
const response = await fetch(`/api/local/tasks/${taskId}/overview-metrics`, { signal });
if (!response.ok) throw new Error(`Overview metrics: ${response.status}`);
return response.json();
}
+73
View File
@@ -0,0 +1,73 @@
// 思考(reasoning)の読み出し(issue #1206 段階 2)。
//
// これまで画面は `logs/reasoning.md` の末尾を読んでいた。ファイルには呼び出しの
// 切れ目が書かれていないので 1 本の文章にしかならない。ここは段階 1 で入れた表を
// 読む口で、**呼び出し 1 回ぶんが 1 件**として返る。
import { BASE } from './client';
/** 呼び出し 1 回ぶんの思考。画面の区切りはこの単位。 */
export interface ReasoningCall {
jobId: string;
attempt: number;
callSeq: number;
piece: string | null;
movement: string | null;
movementVisit: number | null;
iteration: number | null;
/** この思考の直後に来たツール呼び出し。決まっていない件もある。 */
anchorToolCallId: string | null;
anchorKind: 'tool_calls' | 'text' | 'orphan' | null;
text: string;
charCount: number;
truncated: boolean;
ts: string;
firstId: number;
lastId: number;
}
export interface ReasoningCallPage {
/** 古い順。 */
calls: ReasoningCall[];
hasMore: boolean;
nextBefore: number | null;
/** history 取得時にサーバーが解決した現在のジョブ。 */
latestJobId?: string | null;
}
/** 同じ呼び出しを指す鍵。並び順と同一性の権威は id なので、鍵は照合にだけ使う。 */
export function reasoningCallKey(call: ReasoningCall): string {
return `${call.jobId} ${call.attempt} ${call.callSeq}`;
}
export async function fetchReasoningCalls(
taskId: number,
opts: {
limit?: number;
before?: number;
/**
* いちばん新しい実行のぶんだけに絞る。実行中の欄はこれを付ける——付けないと
* 前の実行の思考で枠が出る(#1206 の訴え 1)。どの実行が「新しい方」かは
* サーバーが決めるので、画面はジョブ番号を持たなくてよい。
*/
latestJobOnly?: boolean;
/** メインタスクの全実行を読む。サブタスクは含めない。 */
history?: boolean;
signal?: AbortSignal;
} = {},
): Promise<ReasoningCallPage> {
const qs = new URLSearchParams();
if (opts.limit !== undefined) qs.set('limit', String(opts.limit));
if (opts.before !== undefined) qs.set('before', String(opts.before));
if (opts.history) qs.set('job', 'history');
else if (opts.latestJobOnly) qs.set('job', 'latest');
const suffix = qs.toString() ? `?${qs.toString()}` : '';
const res = await fetch(`${BASE}/local/tasks/${taskId}/reasoning${suffix}`, {
...(opts.signal ? { signal: opts.signal } : {}),
});
if (!res.ok) {
const err = new Error(`reasoning fetch failed: ${res.status}`) as Error & { status?: number };
err.status = res.status;
throw err;
}
return (await res.json()) as ReasoningCallPage;
}
+43
View File
@@ -7,6 +7,8 @@ export interface LatestReflectionForTask {
outcome: string;
memoryChanges: number | null;
pieceEdited: boolean;
/** 記録の置き場。ワークスペースのタスクなら空でない (履歴画面が読む先を決める) */
spaceId?: string | null;
}
export async function getLatestReflectionForTask(
@@ -20,3 +22,44 @@ export async function getLatestReflectionForTask(
if (!data || !data.snapshotId) return null;
return data as LatestReflectionForTask;
}
/**
* 記録(snapshot)の中身。サーバの `SnapshotDetail`(`src/engine/reflection/snapshot.ts`)
* のうち、トレースタブの「学習の結果」が読む項目。トークン数・所要時間は
* 意図して持たない(#1558 段 3-3: 数字は出さず、結果・件数・差分だけ)。
*/
export interface ReflectionSnapshotDetail {
snapshotId: string;
ts: string;
originalJobId: string;
pieceName: string;
outcome: string;
reasoning: string;
memoryChanges: number;
pieceEdited: boolean;
rejections?: Array<{ code: string; name?: string; reason?: string }>;
/** 記憶ファイルの書き換え前(ファイル名 → 本文)。追加なら無い。 */
beforeFiles: Record<string, string>;
/** 記憶ファイルの書き換え後(ファイル名 → 本文)。削除なら無い。 */
afterFiles: Record<string, string>;
pieceBeforeYaml?: string;
pieceAfterYaml?: string;
/** 変更の要約(一行ずつの差分)。 */
diff?: string;
}
/**
* 記録 1 件を読む。ワークスペースのタスクの記録は `data/spaces/{spaceId}` 側にあり、
* `spaceId` を付けないと個人の置き場を探して 404 になる(#1116 と同じ理由)。
* 404 は「記録が残っていない」として null を返す(消された・置き場が違う)。
*/
export async function getReflectionSnapshotDetail(
snapshotId: string,
spaceId?: string | null,
): Promise<ReflectionSnapshotDetail | null> {
const qs = spaceId ? `?spaceId=${encodeURIComponent(spaceId)}` : '';
const res = await fetch(`${BASE}/local/reflection/history/${encodeURIComponent(snapshotId)}${qs}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`getReflectionSnapshotDetail: ${res.status}`);
return (await res.json()) as ReflectionSnapshotDetail;
}
+24 -80
View File
@@ -1,84 +1,6 @@
// api.ts から分割(挙動不変): タスク共有リンク + 公開アプリ共有リンク。
// 公開アプリ共有リンク。
import { BASE } from './client';
import type { LocalTask, LocalTaskComment, SubtaskActivity } from './tasks';
import type { LocalFileEntry } from './task-files';
import type { SubtaskFiles } from './subtasks';
// --- Share ---
export async function shareTask(taskId: number): Promise<{ shareToken: string; shareUrl: string }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to share task');
return data;
}
export async function unshareTask(taskId: number): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/share`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to unshare task');
}
export async function fetchSharedTask(token: string): Promise<LocalTask> {
const res = await fetch(`${BASE}/shared/${token}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Not found');
return data.task;
}
export async function fetchSharedTaskComments(token: string): Promise<LocalTaskComment[]> {
const res = await fetch(`${BASE}/shared/${token}/comments`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch comments');
return data.comments ?? [];
}
export async function fetchSharedFiles(token: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams();
if (path) params.set('path', path);
const res = await fetch(`${BASE}/shared/${token}/files?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export async function fetchSharedFileContent(token: string, path: string): Promise<string> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/shared/${token}/files/content?${params.toString()}`);
if (!res.ok) throw new Error('Failed to read file');
return res.text();
}
export function getSharedFileRawUrl(token: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/shared/${token}/files/raw?${params.toString()}`;
}
export async function fetchSharedSubtaskActivities(token: string): Promise<SubtaskActivity[]> {
const res = await fetch(`${BASE}/shared/${token}/subtasks/activities`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activities');
return data.subtasks ?? [];
}
// 個別サブタスク(共有・read-only)。本体の fetchSubtaskActivity / fetchSubtaskFiles の
// 共有版。TaskDataSource(shared) から使う。封じ込めはサーバ側(share-api.ts)。
export async function fetchSharedSubtaskActivity(token: string, jobId: string): Promise<string> {
const res = await fetch(`${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/activity`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask activity');
return data.activityLog ?? '';
}
export async function fetchSharedSubtaskFiles(token: string, jobId: string): Promise<SubtaskFiles> {
const res = await fetch(`${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/files`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch subtask files');
return { files: data.files ?? [], categories: data.categories ?? {} };
}
export function sharedSubtaskFileRawUrl(token: string, jobId: string, filePath: string): string {
return `${BASE}/shared/${encodeURIComponent(token)}/subtasks/${jobId}/files/${filePath}`;
}
// --- 公開アプリ共有リンク(read-only・認証なし) ---
// スペース内の 1 ワークスペースアプリ(apps/{app}/)をログイン不要の公開トークンで
@@ -105,10 +27,17 @@ export async function fetchAppShareFileContent(token: string, path: string): Pro
return res.text();
}
/**
* 共有アプリから見える 1 フォルダぶんの一覧。
* truncated は、並べた項目のうち「在るのに読めなかった」ものを飛ばしたときに立つ
* (#1320)。消えたもの(リンク切れ・並べた直後に消えたファイル)では立たない。
* 意味は検索の口(fetchAppShareFilesRecursive)の truncated と同じ。画面の知らせは
* まだ足していない。
*/
export async function fetchAppShareFiles(
token: string,
dir: string = '',
): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
): Promise<{ basePath: string; path: string; entries: LocalFileEntry[]; truncated: boolean }> {
const params = new URLSearchParams();
if (dir) params.set('dir', dir);
const qs = params.toString();
@@ -118,11 +47,26 @@ export async function fetchAppShareFiles(
return data;
}
export async function fetchAppShareFilesRecursive(token: string, dir: string): Promise<{ entries: LocalFileEntry[]; truncated: boolean }> {
const params = new URLSearchParams({ dir });
const res = await fetch(`${BASE}/app-share/${token}/files/recursive?${params.toString()}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files recursively');
return data;
}
export function getAppShareRawUrl(token: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/app-share/${token}/files/raw?${params.toString()}`;
}
export async function fetchAppShareBinary(token: string, path: string, signal?: AbortSignal): Promise<Blob> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/app-share/${token}/files/app-binary?${params.toString()}`, { signal });
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? 'Failed to read binary file');
return res.blob();
}
// 発行/失効/取得(canManageSpace)。shareUrl は相対パス(/ui/app/:token)。表示時は
// window.location.origin を前置する。
export async function createAppShareLink(
+25 -1
View File
@@ -5,7 +5,14 @@ import type { LocalFileEntry } from './task-files';
// --- Space files (永続ワークスペース {worktreeDir}/space/{id}/files) ---
// タスク版の files API(getLocalFileRawUrl 等)と同形だが、スペース id でキーする。
export async function fetchSpaceFiles(spaceId: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
/**
* 1 フォルダぶんの一覧。
* truncated は、並べた項目のうち「在るのに読めなかった」ものを飛ばしたときに立つ
* (#1320)。消えたもの(リンク切れ・並べた直後に消えたファイル)では立たない。
* 意味は検索の口(fetchSpaceFilesRecursive)の truncated と同じ。画面の知らせは
* まだ足していない。
*/
export async function fetchSpaceFiles(spaceId: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[]; truncated: boolean }> {
const params = new URLSearchParams();
if (path) params.set('path', path);
const qs = params.toString();
@@ -15,6 +22,16 @@ export async function fetchSpaceFiles(spaceId: string, path: string = ''): Promi
return data;
}
export async function fetchSpaceFilesRecursive(spaceId: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[]; truncated: boolean }> {
const params = new URLSearchParams();
if (path) params.set('path', path);
const qs = params.toString();
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/recursive${qs ? `?${qs}` : ''}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files recursively');
return data;
}
export async function fetchSpaceFileContent(spaceId: string, path: string): Promise<string> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/content?${params.toString()}`);
@@ -30,6 +47,13 @@ export function getSpaceFileRawUrl(spaceId: string, path: string): string {
return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`;
}
export async function fetchSpaceAppBinary(spaceId: string, path: string, signal?: AbortSignal): Promise<Blob> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/app-binary?${params.toString()}`, { signal });
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error ?? 'Failed to read binary file');
return res.blob();
}
export function getSpaceTrustedHtmlUrl(spaceId: string, path: string): string {
const params = new URLSearchParams({ path, trusted: '1' });
return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`;
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createSpaceAutoPrompterRule,
deleteSpaceAutoPrompterRule,
fetchSpaceAutoPrompter,
updateSpaceAutoPrompterEnabled,
updateSpaceAutoPrompterRule,
type AutoPrompterRule,
} from './spaces';
const RULE: AutoPrompterRule = {
id: 'official-docs',
name: 'Check official docs',
version: 1,
enabled: true,
timing: 'tool.before',
target: { toolCategories: ['command_execution'] },
action: { type: 'advise', instruction: 'Check the official documentation.' },
fireLimits: { perTask: 1, perMovement: 1 },
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('workspace Auto Prompter API client', () => {
it('loads the master state and rules', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
JSON.stringify({ enabled: false, rules: [RULE] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
await expect(fetchSpaceAutoPrompter('space/a')).resolves.toEqual({
enabled: false,
rules: [RULE],
});
expect(fetchMock).toHaveBeenCalledWith('/api/local/spaces/space%2Fa/auto-prompter');
});
it('updates only the master switch without deleting rules', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
JSON.stringify({ enabled: false, rules: [RULE] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
await expect(updateSpaceAutoPrompterEnabled('s1', false)).resolves.toEqual({
enabled: false,
rules: [RULE],
});
expect(fetchMock).toHaveBeenCalledWith('/api/local/spaces/s1/auto-prompter', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: false }),
});
});
it('uses versioned payloads for create, update, and delete', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ rule: RULE }), { status: 201 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ rule: { ...RULE, version: 2 } }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
await createSpaceAutoPrompterRule('s1', RULE);
await updateSpaceAutoPrompterRule('s1', RULE);
await deleteSpaceAutoPrompterRule('s1', RULE.id, RULE.version);
expect(fetchMock.mock.calls[0]).toEqual([
'/api/local/spaces/s1/auto-prompter/rules',
expect.objectContaining({ method: 'POST', body: JSON.stringify(RULE) }),
]);
expect(fetchMock.mock.calls[1]).toEqual([
'/api/local/spaces/s1/auto-prompter/rules/official-docs',
expect.objectContaining({ method: 'PUT', body: JSON.stringify(RULE) }),
]);
expect(fetchMock.mock.calls[2]).toEqual([
'/api/local/spaces/s1/auto-prompter/rules/official-docs',
expect.objectContaining({ method: 'DELETE', body: JSON.stringify({ version: 1 }) }),
]);
});
});
+194 -6
View File
@@ -1,5 +1,6 @@
// api.ts から分割(挙動不変): スペース(CRUD・メンバー・招待・ツールポリシー・Python パッケージ)。
import { BASE } from './client';
import type { SpaceTemplate } from '../../../src/spaces/space-templates';
// --- Spaces (個人/案件) ---------------------------------------------------
@@ -191,12 +192,36 @@ export interface ToolCategory {
export interface SpaceToolPolicy {
disabledSafe: string[];
enabledSensitive: string[];
/** 3 値: undefined = 未設定(全体設定を継承)/ true = 許可 / false = 遮断。 */
allowNetwork?: boolean;
}
/** サンドボックスから外部ネットワークへ出られるかの、いまの状態(issue #1015)。 */
export interface SandboxNetworkState {
/** ワークスペースの設定。null = 未設定(全体設定を継承)。 */
workspace: boolean | null;
/** 全体設定(config.yaml の safety.bash_allow_network)。 */
global: boolean;
/** 実際に適用される値。 */
effective: boolean;
/** effective がどちらの設定から来たか。 */
source: 'workspace' | 'global';
/** サンドボックス機構の選択。'off' のときこの切り替えは効かない。 */
sandboxMode: 'auto' | 'always' | 'off';
/**
* 隔離の下地(bwrap)が実際に動いたか(issue #1215)。
* サーバが 1 回だけ走らせた点検の結果。省略されるのは古いサーバの応答だけ。
*/
bwrapAvailable?: boolean;
/** Bash の制限を外す設定。true のときこの切り替えは効かない。 */
bashUnrestricted: boolean;
}
export interface SpaceToolPolicyResponse {
policy: SpaceToolPolicy;
categories: ToolCategory[];
sensitiveTools: { name: string; enabled: boolean }[];
sandboxNetwork?: SandboxNetworkState;
}
export async function fetchSpaceToolPolicy(spaceId: string): Promise<SpaceToolPolicyResponse> {
@@ -208,7 +233,8 @@ export async function fetchSpaceToolPolicy(spaceId: string): Promise<SpaceToolPo
export async function updateSpaceToolPolicy(
spaceId: string,
patch: { disabledSafe?: string[]; enabledSensitive?: string[] },
// allowNetwork: キーを送らなければ現状維持、null で未設定に戻す、boolean で設定。
patch: { disabledSafe?: string[]; enabledSensitive?: string[]; allowNetwork?: boolean | null },
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`, {
method: 'PUT',
@@ -221,6 +247,118 @@ export async function updateSpaceToolPolicy(
}
}
// ─── Workspace Auto Prompter rules ───────────────────────────────────────
export type AutoPrompterTiming = 'always' | 'tool.before' | 'completion.before_accept';
export type AutoPrompterToolCategory =
| 'command_execution'
| 'internet_search'
| 'file_read'
| 'file_write'
| 'external_communication'
| 'browser'
| 'ssh';
export type AutoPrompterAction =
| { type: 'allow' }
| { type: 'deny'; reason: string }
| { type: 'advise'; instruction: string }
| { type: 'approval'; message: string };
export interface AutoPrompterRule {
id: string;
name?: string;
condition?: string;
version: number;
enabled: boolean;
timing: AutoPrompterTiming;
target?: {
toolNames?: string[];
toolCategories?: AutoPrompterToolCategory[];
};
scope?: {
pieces?: string[];
movements?: string[];
};
fireLimits?: {
perTask?: number;
perMovement?: number;
};
action: AutoPrompterAction;
}
export interface SpaceAutoPrompterResponse {
enabled: boolean;
rules: AutoPrompterRule[];
}
const autoPrompterBase = (spaceId: string): string =>
`${BASE}/local/spaces/${encodeURIComponent(spaceId)}/auto-prompter`;
const autoPrompterResponse = async (res: Response): Promise<SpaceAutoPrompterResponse> => {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to update Auto Prompter');
return data as SpaceAutoPrompterResponse;
};
export async function fetchSpaceAutoPrompter(spaceId: string): Promise<SpaceAutoPrompterResponse> {
return autoPrompterResponse(await fetch(autoPrompterBase(spaceId)));
}
export async function updateSpaceAutoPrompterEnabled(
spaceId: string,
enabled: boolean,
): Promise<SpaceAutoPrompterResponse> {
return autoPrompterResponse(await fetch(autoPrompterBase(spaceId), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}));
}
export async function createSpaceAutoPrompterRule(
spaceId: string,
rule: AutoPrompterRule,
): Promise<AutoPrompterRule> {
const res = await fetch(`${autoPrompterBase(spaceId)}/rules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to create Auto Prompter rule');
return data.rule as AutoPrompterRule;
}
export async function updateSpaceAutoPrompterRule(
spaceId: string,
rule: AutoPrompterRule,
): Promise<AutoPrompterRule> {
const res = await fetch(`${autoPrompterBase(spaceId)}/rules/${encodeURIComponent(rule.id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to update Auto Prompter rule');
return data.rule as AutoPrompterRule;
}
export async function deleteSpaceAutoPrompterRule(
spaceId: string,
ruleId: string,
version: number,
): Promise<void> {
const res = await fetch(`${autoPrompterBase(spaceId)}/rules/${encodeURIComponent(ruleId)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to delete Auto Prompter rule');
}
}
// ─── Per-space Python packages ────────────────────────────────────────────
export interface SpacePythonPackage {
name: string;
@@ -288,11 +426,27 @@ export interface SpaceWebhook {
// URL once saved (write-only secret).
}
export async function fetchSpaceWebhooks(spaceId: string): Promise<SpaceWebhook[]> {
export interface SpaceWebhookList {
webhooks: SpaceWebhook[];
/**
* Server-side master switch (`webhooks.enabled` in config.yaml). When
* false the delivery subsystem was never started, so a test send is
* refused before any connection is opened. The screen has to say so —
* without it the only feedback was a bare "送信失敗" (issue #1125).
*/
deliveryEnabled: boolean;
}
export async function fetchSpaceWebhooks(spaceId: string): Promise<SpaceWebhookList> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch webhooks');
return (data?.webhooks ?? []) as SpaceWebhook[];
return {
webhooks: (data?.webhooks ?? []) as SpaceWebhook[],
// Absent field → treated as enabled. Blocking test sends on a server
// that is actually delivering would be the worse mistake of the two.
deliveryEnabled: data?.deliveryEnabled !== false,
};
}
export async function createSpaceWebhook(
@@ -347,14 +501,43 @@ export async function deleteSpaceWebhook(spaceId: string, webhookId: string): Pr
}
}
/**
* Why a test send failed, so the screen can word it differently (issue
* #1125): delivery is switched off server-side, the destination answered and
* refused (`status` carries its HTTP status), or nothing answered at all.
*/
export type SpaceWebhookTestFailureCode =
| 'delivery_disabled'
| 'rejected'
| 'network'
| 'blocked'
| 'invalid_url'
| 'not_found'
| 'not_implemented'
| 'internal';
export interface SpaceWebhookTestResult {
ok: boolean;
error?: string;
code?: SpaceWebhookTestFailureCode;
status?: number;
}
export async function testSendSpaceWebhook(
spaceId: string,
webhookId: string,
): Promise<{ ok: boolean; error?: string }> {
): Promise<SpaceWebhookTestResult> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}/test`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: data?.error ?? 'Failed to send test notification' };
return data as { ok: boolean; error?: string };
if (!res.ok) {
return {
ok: false,
error: data?.error ?? 'Failed to send test notification',
code: data?.code,
status: data?.status,
};
}
return data as SpaceWebhookTestResult;
}
export async function createSpace(input: {
@@ -362,6 +545,11 @@ export async function createSpace(input: {
description?: string;
brandColor?: string | null;
visibility?: Space['visibility'];
/**
* 用途テンプレート(issue #993 S2-3)。差し替わるのは AGENTS.md の雛形だけで、
* ワークスペースの属性としては残らない。未指定なら従来どおりの雛形。
*/
template?: SpaceTemplate;
}): Promise<Space> {
const res = await fetch(`${BASE}/local/spaces`, {
method: 'POST',
+94
View File
@@ -0,0 +1,94 @@
/**
* 履歴の取得口(issue #989)。
*
* 画面のテストは取得口を差し替えるので、**どの URL をどう叩くか**はここで固定する。
* ここが無いと、URL を間違えても画面のテストは全部通ってしまう。
* サーバー側の受け口(local-file-history-api.test.ts)と対になる。
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import {
fetchFileHistory,
fetchFileVersionDiff,
getFileVersionContentUrl,
restoreFileVersion,
} from './task-file-history';
function stubFetch(response: () => Response) {
const seen: { url: string; init?: RequestInit }[] = [];
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => {
seen.push({ url, init });
return Promise.resolve(response());
});
return seen;
}
const json = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
afterEach(() => vi.unstubAllGlobals());
describe('fetchFileHistory', () => {
it('区分とパスを付けて一覧を取り、記録の有無ごと返す', async () => {
const seen = stubFetch(() => json({ recording: true, versions: [{ commit: 'a'.repeat(40), ts: 't', source: 'agent_output', jobId: null, truncatedRoot: false }] }));
const history = await fetchFileHistory(7, 'output', 'sub/a.md');
expect(history.recording).toBe(true);
expect(history.versions).toHaveLength(1);
expect(seen[0]!.url).toContain('/local/tasks/7/files/history?');
expect(seen[0]!.url).toContain('section=output');
expect(seen[0]!.url).toContain('path=sub%2Fa.md');
});
it('断られたらエラーの中身を伝える', async () => {
stubFetch(() => json({ error: 'Task not found' }, 404));
await expect(fetchFileHistory(7, 'output', 'a.md')).rejects.toThrow('Task not found');
});
});
describe('fetchFileVersionDiff', () => {
it('2 つの版を渡す', async () => {
const seen = stubFetch(() => json({ binary: false, diff: 'x', truncated: false }));
await fetchFileVersionDiff(7, 'output', 'a.md', 'b'.repeat(40), 'a'.repeat(40));
expect(seen[0]!.url).toContain('to=' + 'b'.repeat(40));
expect(seen[0]!.url).toContain('from=' + 'a'.repeat(40));
});
it('比較元が無い(最初の版)なら from を送らない', async () => {
const seen = stubFetch(() => json({ binary: false, diff: 'x', truncated: false }));
await fetchFileVersionDiff(7, 'output', 'a.md', 'b'.repeat(40), null);
expect(seen[0]!.url).not.toContain('from=');
});
});
describe('restoreFileVersion', () => {
it('POST で区分・パス・版を送る', async () => {
const seen = stubFetch(() => json({ ok: true }));
await restoreFileVersion(7, 'output', 'a.md', 'c'.repeat(40));
expect(seen[0]!.url).toContain('/local/tasks/7/files/history/restore');
expect(seen[0]!.init?.method).toBe('POST');
expect(JSON.parse(String(seen[0]!.init?.body))).toEqual({ section: 'output', path: 'a.md', commit: 'c'.repeat(40) });
});
it('実行中に断られたら、その理由を伝える', async () => {
stubFetch(() => json({ error: 'Cannot modify files while job is running' }, 409));
await expect(restoreFileVersion(7, 'output', 'a.md', 'c'.repeat(40)))
.rejects.toThrow('Cannot modify files while job is running');
});
});
describe('getFileVersionContentUrl', () => {
it('版を指したダウンロード URL を組む', () => {
const url = getFileVersionContentUrl(7, 'input', 'sub/a.md', 'd'.repeat(40));
expect(url).toContain('/local/tasks/7/files/history/content?');
expect(url).toContain('section=input');
expect(url).toContain('path=sub%2Fa.md');
expect(url).toContain('commit=' + 'd'.repeat(40));
});
});
+97
View File
@@ -0,0 +1,97 @@
// ファイル履歴(issue #989): 版の一覧・版の中身・差分・復元。
//
// 差分はサーバー側で作る。画面は受け取った unified diff を色分けするだけで、
// 差分計算は持たない(記録が git なので、差分はすでにそこにある)。
import { BASE } from './client';
export type FileHistorySection = 'workspace' | 'input' | 'output' | 'logs';
export interface FileVersion {
commit: string;
/** 記録された時刻(ISO 8601) */
ts: string;
/** 出どころ(agent_output / bash_generated / ui_restore / pre_restore など) */
source: string;
jobId: string | null;
/** この版より前が保持上限で切り落とされている */
truncatedRoot: boolean;
}
export interface FileHistory {
/** false = このワークスペースでは履歴を記録していない(「まだ版が無い」とは別) */
recording: boolean;
versions: FileVersion[];
}
export interface FileVersionDiff {
binary: boolean;
diff: string;
truncated: boolean;
}
export async function fetchFileHistory(
taskId: number,
section: FileHistorySection,
path: string,
): Promise<FileHistory> {
const qs = new URLSearchParams({ section, path });
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/history?${qs.toString()}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to load file history');
return data as FileHistory;
}
export async function fetchFileVersionDiff(
taskId: number,
section: FileHistorySection,
path: string,
to: string,
from: string | null,
): Promise<FileVersionDiff> {
const qs = new URLSearchParams({ section, path, to });
if (from) qs.set('from', from);
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/history/diff?${qs.toString()}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to load diff');
return data as FileVersionDiff;
}
export function getFileVersionContentUrl(
taskId: number,
section: FileHistorySection,
path: string,
commit: string,
): string {
const qs = new URLSearchParams({ section, path, commit });
return `${BASE}/local/tasks/${taskId}/files/history/content?${qs.toString()}`;
}
/**
* 復元が断られたときのエラー。`code` はサーバーが返す機械可読な理由で、
* 画面はこれを見て日本語/英語の文言を選ぶ(サーバーの英文をそのまま出さない)。
*/
export class FileVersionRestoreError extends Error {
readonly code: string | undefined;
constructor(message: string, code?: string) {
super(message);
this.name = 'FileVersionRestoreError';
this.code = code;
}
}
export async function restoreFileVersion(
taskId: number,
section: 'input' | 'output',
path: string,
commit: string,
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/history/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, path, commit }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new FileVersionRestoreError(data?.error ?? 'Failed to restore version', data?.code);
}
}
@@ -0,0 +1,58 @@
/**
* プレビューが断られたときの 413 を読む配線(#1084 レビュー P2-3)。
*
* サーバーは数値だけ返し、文言は画面が組む。その受け渡しの 1 行が抜けると、
* 画面は「翻訳できる材料が無い」状態になり、英語の原文へ落ちる。ここでは
* **サーバーの応答が数値のまま届くこと**と、版がずれても英語が漏れない
* ことを見る。文言そのものは FilePreview.too-large.dom が見ている。
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { fetchOfficePreview, OfficePreviewError } from './task-files';
function stubJson(status: number, body: unknown) {
vi.stubGlobal('fetch', () => Promise.resolve(new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})));
}
async function failureOf(status: number, body: unknown): Promise<OfficePreviewError> {
stubJson(status, body);
try {
await fetchOfficePreview('/api/local/tasks/1/files/office-preview?path=book.xlsx');
} catch (e) {
return e as OfficePreviewError;
}
throw new Error('expected fetchOfficePreview to reject');
}
afterEach(() => vi.unstubAllGlobals());
describe('fetchOfficePreview の 413', () => {
it('ファイルの大きさで断られたとき、実測値と上限をそのまま受け取る', async () => {
const err = await failureOf(413, { error: 'file_too_large', reason: 'file_size', actualMb: 62.4, limitMb: 50 });
expect(err.code).toBe('file_too_large');
expect(err.tooLarge).toEqual({ reason: 'file_size', actualMb: 62.4, limitMb: 50 });
});
it('項目数で断られたときも、件数と上限を受け取る', async () => {
const err = await failureOf(413, { error: 'file_too_large', reason: 'entries', actualEntries: 12000, limitEntries: 10000 });
expect(err.tooLarge).toEqual({ reason: 'entries', actualEntries: 12000, limitEntries: 10000 });
});
it('知らない理由が来ても 413 だったことは伝える(画面は汎用の文へ落ちる)', async () => {
const err = await failureOf(413, { error: 'file_too_large', reason: 'something-new' });
expect(err.tooLarge?.reason).toBe('unknown');
});
it('大きさ以外の失敗には、断りの中身を付けない', async () => {
const err = await failureOf(503, { error: 'converter_unavailable', message: 'soffice missing' });
expect(err.code).toBe('converter_unavailable');
expect(err.tooLarge).toBeUndefined();
expect(err.message).toBe('soffice missing');
});
});
+410 -8
View File
@@ -13,6 +13,13 @@ export interface FileProvenance {
firstSeenAt: string | null;
lastModifiedByTaskId: number | null;
lastModifiedAt: string | null;
/**
* 置いた人の表示名 (#1287)。users.id は返らない。名前が出せないとき(台帳に
* 人が入っていない・認証なしの環境・消えた利用者)は null。
*/
uploadedByName: string | null;
/** 最後に書き換えた人の表示名 (#1287)。null の意味は uploadedByName と同じ。 */
lastModifiedByName: string | null;
}
/** Fetch the provenance record for one workspace file (null when untracked). */
@@ -28,15 +35,34 @@ export async function fetchFileProvenance(
return (data.provenance ?? null) as FileProvenance | null;
}
/**
* 一覧の 1 件に添えて返る台帳の要点(#1280 第 3 段)。台帳に行が無いファイルには
* 付かない。中身は FileProvenance と同じ安全な範囲の一部で、置いた人は入らない。
*/
export interface FileOrigin {
sourceKind: string;
createdByTaskId: number | null;
lastModifiedByTaskId: number | null;
}
export interface LocalFileEntry {
name: string;
path: string;
kind: 'directory' | 'file';
size: number;
modifiedAt: string;
/** 台帳に行があるときだけ入る。無いファイル(台帳を入れる前からあるもの)では undefined。 */
origin?: FileOrigin;
}
export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
/**
* 1 フォルダぶんの一覧。
* truncated は、並べた項目のうち「在るのに読めなかった」ものを飛ばしたときに立つ
* (#1313)。消えたもの(リンク切れ・並べた直後に消えたファイル)では立たない。
* 意味は再帰の口(fetchLocalFilesRecursive)の truncated と同じ。画面の知らせは
* まだ足していない。
*/
export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[]; truncated: boolean }> {
const params = new URLSearchParams({ section });
if (path) params.set('path', path);
const res = await fetch(`${BASE}/local/tasks/${taskId}/files?${params.toString()}`);
@@ -45,6 +71,29 @@ export async function fetchLocalFiles(taskId: number, section: 'workspace' | 'in
return data;
}
/**
* フォルダ配下すべてのファイルを 1 回で取る(#1308)。ファイルタブの検索は、
* 検索語が入っているときだけこの口を叩く(空のときは fetchLocalFiles のまま)。
* サーバ側の歯止め(ファイル 1000 件・深さ 10・訪問 2000 件)のどれかに当たると
* truncated が true になる。フォルダが多い置き場では訪問数がいちばん先に効くので、
* 画面の文言に「1000 件まで見た」とは書かない(#1308 レビュー P2-2)。
* 読めないフォルダがあったときも truncated が立つので、文言で理由を 1 つに
* 決めつけない(#1308 レビュー P2-A)。
* フォルダは返らない。path は返る entries の path にも接頭辞として残る。
*/
export async function fetchLocalFilesRecursive(
taskId: number,
section: 'workspace' | 'input' | 'output' | 'logs',
path: string = '',
): Promise<{ basePath: string; path: string; entries: LocalFileEntry[]; count: number; truncated: boolean }> {
const params = new URLSearchParams({ section });
if (path) params.set('path', path);
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/recursive?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files recursively');
return data;
}
export async function fetchLocalFileContent(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): Promise<string> {
const params = new URLSearchParams({ section, path });
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`);
@@ -55,6 +104,202 @@ export async function fetchLocalFileContent(taskId: number, section: 'workspace'
return await res.text();
}
/** 窓 1 回ぶんの読み取り結果。位置はすべてバイト数(サーバ側 FileWindow と対)。 */
export interface LocalFileWindow {
content: string;
/** 実際に読み始めた位置(文字・行の境界に寄せた後)。 */
offset: number;
/** 次に続きを取るとき渡す位置。 */
nextOffset: number;
/** ファイル全体のバイト数。 */
size: number;
/** 手前か後ろに読んでいない部分があるとき true。 */
truncated: boolean;
}
/**
* ファイルの一部だけを読む (#1002 / #1008)。
*
* `offset` を省くと末尾の `limit` バイトを読む。続きは戻り値の `nextOffset` を
* 渡して取る。実行中ずっと追記され続ける推論の記録を、全文取り直しなしで
* 追いかけるための入口。表示中に画面を離れたら中断できるよう signal を受ける。
*/
export async function fetchLocalFileWindow(
taskId: number,
section: 'workspace' | 'input' | 'output' | 'logs',
path: string,
opts: { offset?: number; limit: number; signal?: AbortSignal },
): Promise<LocalFileWindow> {
const params = new URLSearchParams({ section, path, limit: String(opts.limit) });
if (opts.offset !== undefined) params.set('offset', String(opts.offset));
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content?${params.toString()}`, {
...(opts.signal ? { signal: opts.signal } : {}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
// 状態コードを添える: まだ記録が無いだけの 404 と本当の失敗を、呼び出し側が
// 分けて扱えるようにする(無い状態でエラーを出すのは雑音)。
throw Object.assign(new Error(data?.error ?? 'Failed to read file'), { status: res.status });
}
// 位置のヘッダが欠けていたら失敗として扱う。0 に落とすと「先頭から読み直す」
// 指示になり、継ぎ足しの経路で最初の 1 窓を毎回二重に足し続ける。
// (逆プロキシが X-* を落とす構成で起きうる。同一オリジンの今は起きない)
const num = (name: string) => {
const raw = res.headers.get(name);
const value = raw === null ? NaN : Number(raw);
if (!Number.isFinite(value)) throw new Error(`Missing or invalid ${name} header`);
return value;
};
const offset = num('X-Content-Offset');
const nextOffset = num('X-Content-Next-Offset');
const size = num('X-Content-Size');
return {
content: await res.text(),
offset,
nextOffset,
size,
truncated: res.headers.get('X-Content-Truncated') === '1',
};
}
/**
* 書き込みがサーバに断られたときの型。`code` は画面が文言を選ぶための目印で、
* 権限不足なら `'no_edit_permission'` が入る(#1530)。
*/
export class FileWriteError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = 'FileWriteError';
if (code) this.code = code;
}
}
/** 保存が断られた理由を、画面側が翻訳して出せるようにするための型。 */
export class XlsxCellsSaveError extends Error {
/** サーバが返したコード('source_changed' 等)。 */
code?: string;
/**
* 断りの原因になったセル(#1351)。コピーした式のおおもとを直したときに、
* 残りのマスの式を書き直せなかった場合に付く。**どのセルが原因か**を画面に
* 出すために要る——理由だけ出しても、利用者はどこを直せばよいか分からない。
*/
cells?: string[];
/** そのかたまりの範囲(`C2:C4` など)。`cells` と同じ並び。 */
ranges?: string[];
/** 列を入れられなかった理由の符号(#1540)。画面が訳す。 */
reasons?: string[];
/** そのとき見つかったもの(要素名・関連付けの種類・部品名など・#1540)。 */
unsupportedElements?: string[];
/** 断られたシート(#1540)。 */
sheet?: string;
constructor(
message: string,
code?: string,
detail?: { cells?: unknown; ranges?: unknown; reasons?: unknown; unsupportedElements?: unknown; sheet?: unknown },
) {
super(message);
this.name = 'XlsxCellsSaveError';
if (code) this.code = code;
const cells = detail?.cells;
if (Array.isArray(cells)) this.cells = cells.map(String);
const ranges = detail?.ranges;
if (Array.isArray(ranges)) this.ranges = ranges.map(String);
const reasons = detail?.reasons;
if (Array.isArray(reasons)) this.reasons = reasons.map(String);
const found = detail?.unsupportedElements;
if (Array.isArray(found)) this.unsupportedElements = found.map(String);
if (typeof detail?.sheet === 'string') this.sheet = detail.sheet;
}
}
/**
* 保存の口が受けるセル 1 つぶんの指定。書式の欄名は**下線つなぎ**で、サーバ側の
* `parseCellEdits` が読む名前とそろえてある(#1322 段 1 で書式が乗った)。
*/
export interface XlsxCellEditPayload {
sheet?: string;
cell: string;
value?: string | number | boolean | null;
formula?: string;
style?: {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
font_name?: string | null;
font_size?: number | null;
font_color?: string | null;
fill_color?: string | null;
border?: { edges?: ('top' | 'right' | 'bottom' | 'left')[]; style?: string; color?: string } | null;
alignment?: { horizontal?: string; vertical?: string; wrap_text?: boolean;
text_rotation?: number | 'vertical' | null } | null;
};
}
export interface XlsxMergeEditPayload {
sheet?: string;
range: string;
merged: boolean;
}
/** 列幅(Excel の文字幅単位)。null で既定幅に戻す。 */
export interface XlsxColumnEditPayload {
sheet?: string;
columns: string;
width: number | null;
}
/** 行の高さ(ポイント)。null で自動に戻す。 */
export interface XlsxRowEditPayload {
sheet?: string;
row: number;
height: number | null;
}
/**
* 画面で直した Excel のセルを保存する(#1035 段階 2)。
*
* 指定したセル以外は変えない経路(サーバ側 xlsx-edit)を通る。実行中や
* 持ち主以外の保存はサーバが断る。
*
* `expectedSourceSha256` は**開いたときのファイルの検査値**。プレビューが返した
* 値をそのまま返す。その間に誰かが同じファイルを変えていたらサーバが断るので、
* タブを 2 枚開いても後勝ちで上書きしない(#1067)。
*
* 返事の `source_sha256` は**保存し終えたファイルの検査値**(#1463)。続けて
* 保存するときはこれを次の `expectedSourceSha256` に使う。取り直さずに続けて
* 保存できるのはこの値があるからで、無ければ画面を取り直して拾い直すしかない。
*/
export async function updateLocalXlsxCells(
taskId: number,
section: 'input' | 'output',
path: string,
edits: XlsxCellEditPayload[],
expectedSourceSha256: string,
rows: XlsxRowEditPayload[] = [],
columns: XlsxColumnEditPayload[] = [],
structural: XlsxStructuralOpPayload[] = [],
merges: XlsxMergeEditPayload[] = [],
): Promise<{ sheets: string[]; cells: number; rows?: number; structural?: number; source_sha256?: string }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/xlsx-cells`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
section, path, edits, expected_source_sha256: expectedSourceSha256,
...(rows.length > 0 ? { rows } : {}),
...(columns.length > 0 ? { columns } : {}),
// 列の挿入(#1540)。サーバは**これを先に**当ててから `edits` を当てるので、
// `edits` の番地は入れた後のものでなければならない。
...(structural.length > 0 ? { structural } : {}),
...(merges.length > 0 ? { merges } : {}),
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new XlsxCellsSaveError(data?.error ?? 'Failed to save cells', data?.code, data);
return data as { sheets: string[]; cells: number; rows?: number; structural?: number; source_sha256?: string };
}
export function getLocalFileRawUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path });
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
@@ -65,8 +310,15 @@ export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'i
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
// ── Office プレビュー (Excel / PowerPoint) ───────────────────────────────
// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)に変換して返す。
import type { SheetGrid } from '../lib/spreadsheet-grid';
import type { SheetStructuralEdit, XlsxStructuralOpPayload } from '../lib/spreadsheet-structural';
// 保存の口へ送る構造の操作(#1540)。型そのものは lib 側にあり、ここは通し口。
export type { XlsxStructuralOpPayload } from '../lib/spreadsheet-structural';
// ── Office プレビュー (Excel / PowerPoint / Word / Outlook .msg) ─────────
// サーバが Excel→シートのセル配列、PPTX→スライド画像・DOCX→ページ画像(PNG data URL)、
// .msg→メールのヘッダー+本文(JSON)に変換して返す。
export interface OfficeSpreadsheetSheet {
name: string;
@@ -74,11 +326,29 @@ export interface OfficeSpreadsheetSheet {
rowCount: number;
colCount: number;
truncated: boolean;
/**
* 書式つきの中身(#1264)。表計算らしい格子はこちらを描く。素の表は `rows` の
* ままで、読み上げソフト向けの逃げ道として残る。
*
* 任意にしてあるのは、配信をまたいで開いたままのタブが古いサーバから受け取る
* 場合があるため。無ければ格子への切り替えを出さない。
*/
grid?: SheetGrid;
/**
* このシートで列を入れてよいか(#1540 段 1)。判定の持ち主はサーバ
* (`src/office/xlsx-structural-gate.ts`)で、画面は理由の符号を訳すだけ。
*
* `grid` と同じく任意にしてある。古いサーバに繋がったままのタブには届かず、
* そのときは列の挿入を通さない。
*/
structuralEdit?: SheetStructuralEdit;
}
export interface OfficeSpreadsheetPreview {
kind: 'spreadsheet';
sheets: OfficeSpreadsheetSheet[];
truncated: boolean;
/** いま映しているファイルの検査値。保存のときに送り返して、後勝ちを防ぐ。 */
sourceSha256: string;
}
export interface OfficePresentationPreview {
kind: 'presentation';
@@ -92,24 +362,85 @@ export interface OfficeDocumentPreview {
pageCount: number;
truncated: boolean;
}
export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview;
export interface OfficeEmailAddress {
name?: string;
email?: string;
}
export interface OfficeEmailPreview {
kind: 'email';
subject?: string;
from?: OfficeEmailAddress;
to: OfficeEmailAddress[];
cc: OfficeEmailAddress[];
date?: string;
body: {
/** 未 sanitize の HTML。描画側は DOMPurify + sandbox iframe を必ず通すこと。 */
html?: string;
text: string;
format: 'html' | 'plain' | 'none';
truncated: boolean;
};
attachments: { fileName: string; contentLength?: number; embeddedMessage?: boolean }[];
}
export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview | OfficeDocumentPreview | OfficeEmailPreview;
/**
* 大きすぎて映せなかったときにサーバが返す中身(413)。**数値だけ**で、文言は
* 入っていない。画面の言語で組み直すため(#1084 レビュー P2-3)。
*/
export interface OfficePreviewTooLargeDetail {
/** 'unknown' はサーバと画面の版がずれたとき。画面は汎用の文を出す。 */
reason: 'file_size' | 'uncompressed' | 'entries' | 'unknown';
actualMb?: number;
limitMb?: number;
actualEntries?: number;
limitEntries?: number;
}
/** office-preview エンドポイントの失敗を、変換エンジン未導入(503)とそれ以外で区別できる型。 */
export class OfficePreviewError extends Error {
/** サーバが返した error コード ('converter_unavailable' 等)。 */
code?: string;
constructor(message: string, code?: string) {
/** 413 のときだけ入る、断った理由と数値。 */
tooLarge?: OfficePreviewTooLargeDetail;
constructor(message: string, code?: string, tooLarge?: OfficePreviewTooLargeDetail) {
super(message);
this.name = 'OfficePreviewError';
this.code = code;
if (tooLarge) this.tooLarge = tooLarge;
}
}
const TOO_LARGE_REASONS = ['file_size', 'uncompressed', 'entries'] as const;
function parseTooLarge(data: Record<string, unknown>): OfficePreviewTooLargeDetail | undefined {
if (data?.error !== 'file_too_large') return undefined;
// reason が読めなくても 413 だったことは伝える。画面はそのとき汎用の文を出す
// (サーバと画面の版がずれても、英語の原文が漏れない)。
const reason = TOO_LARGE_REASONS.find(r => r === data?.reason) ?? 'unknown';
const num = (key: string): number | undefined => (typeof data?.[key] === 'number' ? data[key] as number : undefined);
const actualMb = num('actualMb');
const limitMb = num('limitMb');
const actualEntries = num('actualEntries');
const limitEntries = num('limitEntries');
return {
reason,
...(actualMb === undefined ? {} : { actualMb }),
...(limitMb === undefined ? {} : { limitMb }),
...(actualEntries === undefined ? {} : { actualEntries }),
...(limitEntries === undefined ? {} : { limitEntries }),
};
}
export async function fetchOfficePreview(url: string): Promise<OfficePreview> {
const res = await fetch(url);
if (!res.ok) {
const data = await res.json().catch(() => ({} as { error?: string; message?: string }));
throw new OfficePreviewError(data?.message ?? data?.error ?? 'Failed to load preview', data?.error);
const data = await res.json().catch(() => ({} as Record<string, unknown>)) as Record<string, unknown>;
const message = (typeof data?.message === 'string' ? data.message : undefined)
?? (typeof data?.error === 'string' ? data.error : undefined)
?? 'Failed to load preview';
const code = typeof data?.error === 'string' ? data.error : undefined;
throw new OfficePreviewError(message, code, parseTooLarge(data));
}
return (await res.json()) as OfficePreview;
}
@@ -186,6 +517,77 @@ export async function updateLocalFileContent(taskId: number, section: string, pa
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
// 断りの理由(`code`)を落とさずに投げる。サーバの英語の一文をそのまま
// 画面に出すと、権限の話が「Task not found」のような別の意味で読まれる(#1530)。
throw new FileWriteError(err.error || res.statusText, err.code);
}
}
/**
* ごみ箱の 1 件(#1530 第 2 段)。サーバの `GET files/trash` が返す形をそのまま写す
* (`src/bridge/local-files-trash-api.ts`)。`deletedByUserId` は使わないので載せない
* ——画面に出すのは表示名だけで、認証を使っていない環境や消えた利用者では null になる。
*/
export interface TaskTrashEntry {
/** ごみ箱の置き場の鍵。戻す・完全に消すはこれを渡す。 */
id: string;
/** 元の区分(input / output)。 */
section: string;
/** 区分から見た元の相対パス。 */
path: string;
/** 元の名前(パスの末尾)。 */
name: string;
kind: 'file' | 'directory';
size: number;
/** ISO8601。 */
deletedAt: string;
/** 消した人の表示名。出せないときは null。 */
deletedByName: string | null;
}
/** ごみ箱の中身。見られる人なら誰でも取れる(消せる人には絞られていない)。 */
export async function fetchTaskTrash(taskId: number): Promise<{ entries: TaskTrashEntry[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/trash`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to load trash');
return data as { entries: TaskTrashEntry[] };
}
/** 1 件の復元の結果。`renamed` が true なら元の名前が空いておらず別名で戻っている。 */
export interface TaskTrashRestored {
id: string;
section: string;
/** 実際に戻した先(区分から見た相対パス)。 */
path: string;
renamed: boolean;
}
/** ごみ箱から元の場所へ戻す。戻し先が埋まっていればサーバが別名へ逃がす。 */
export async function restoreTaskTrash(
taskId: number,
ids: string[],
): Promise<{ restored: TaskTrashRestored[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/trash/restore`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to restore from trash');
return data as { restored: TaskTrashRestored[]; skipped: string[] };
}
/** ごみ箱の中身を本当に消す。戻せなくなる。 */
export async function purgeTaskTrash(
taskId: number,
ids: string[],
): Promise<{ purged: string[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/trash/purge`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to purge trash');
return data as { purged: string[]; skipped: string[] };
}
+86
View File
@@ -0,0 +1,86 @@
/**
* 成長し続けるファイルを窓で読む取得口 (#1002 / #1008)。
*
* 推論の記録 (logs/reasoning.md) はタスクの実行中ずっと追記され、切り詰められない。
* 全文を取り直す作りでは長いタスクで破綻するので、末尾の窓だけ読み、次からは
* サーバが返した続きの位置から差分だけ取る。位置とサイズはヘッダで返る。
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { fetchLocalFileWindow } from './task-files';
const windowResponse = (body: string, headers: Record<string, string>): Response =>
new Response(body, { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', ...headers } });
/** 呼ばれた URL と fetch オプションを覚える stub。 */
function stubFetch(response: () => Response) {
const seen: { url: string; init?: RequestInit }[] = [];
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => {
seen.push({ url, init });
return Promise.resolve(response());
});
return seen;
}
afterEach(() => vi.unstubAllGlobals());
describe('fetchLocalFileWindow', () => {
it('末尾の窓を読み、続きの位置とサイズを返す', async () => {
const seen = stubFetch(() => windowResponse('末尾の推論', {
'X-Content-Offset': '4096',
'X-Content-Next-Offset': '4111',
'X-Content-Size': '4111',
'X-Content-Truncated': '1',
}));
const win = await fetchLocalFileWindow(7, 'logs', 'reasoning.md', { limit: 1024 });
expect(win).toEqual({ content: '末尾の推論', offset: 4096, nextOffset: 4111, size: 4111, truncated: true });
expect(seen[0].url).toContain('/local/tasks/7/files/content?');
expect(seen[0].url).toContain('limit=1024');
// 末尾読みでは offset を送らない(サーバが末尾を選ぶ)。
expect(seen[0].url).not.toContain('offset=');
});
it('続きの位置を渡すと差分だけ取りに行く', async () => {
const seen = stubFetch(() => windowResponse('増えた分', {
'X-Content-Offset': '4111',
'X-Content-Next-Offset': '4123',
'X-Content-Size': '4123',
'X-Content-Truncated': '0',
}));
const win = await fetchLocalFileWindow(7, 'logs', 'reasoning.md', { offset: 4111, limit: 1024 });
expect(win.content).toBe('増えた分');
expect(win.truncated).toBe(false);
expect(seen[0].url).toContain('offset=4111');
});
it('中断シグナルをそのまま渡す', async () => {
const seen = stubFetch(() => windowResponse('', {
'X-Content-Offset': '0', 'X-Content-Next-Offset': '0', 'X-Content-Size': '0', 'X-Content-Truncated': '0',
}));
const controller = new AbortController();
await fetchLocalFileWindow(7, 'logs', 'reasoning.md', { limit: 16, signal: controller.signal });
expect(seen[0].init?.signal).toBe(controller.signal);
});
it('位置のヘッダが無ければ失敗として扱う(0 と解釈すると継ぎ足しが二重になる)', async () => {
// 逆プロキシが X-* を落とす構成で起きうる。0 に落とすと「先頭から読み直す」
// 指示になり、差分の経路で最初の 1 窓を毎回二重に足し続ける。
stubFetch(() => windowResponse('本文', { 'X-Content-Truncated': '0' }));
await expect(fetchLocalFileWindow(7, 'logs', 'reasoning.md', { offset: 100, limit: 1024 }))
.rejects.toThrow(/X-Content-Offset/);
});
it('失敗はエラーとして投げ、区別できるよう状態コードを添える', async () => {
stubFetch(() => new Response(JSON.stringify({ error: 'not found' }), { status: 404 }));
// まだ記録が無いだけの 404 と、本当の失敗を呼び出し側で分けられるようにする。
await expect(fetchLocalFileWindow(7, 'logs', 'reasoning.md', { limit: 16 }))
.rejects.toMatchObject({ message: 'not found', status: 404 });
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* 本文の保存が断られたときに、断りの目印(`code`)を落とさないこと(#1530)。
*
* ここが素の Error に潰れると、画面は英語の一文しか持たないので、権限の話を
* 権限の文言で出せなくなる。
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { updateLocalFileContent, FileWriteError } from './task-files';
function mockFetch(status: number, body: unknown) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
statusText: String(status),
json: async () => body,
}));
}
afterEach(() => vi.unstubAllGlobals());
describe('updateLocalFileContent', () => {
it('権限不足は code つきで投げる', async () => {
mockFetch(403, { error: 'No permission to edit files in this task', code: 'no_edit_permission' });
await expect(updateLocalFileContent(1, 'output', 'memo.md', 'x'))
.rejects.toMatchObject({ code: 'no_edit_permission' });
});
it('code の無い断りは code を持たない', async () => {
mockFetch(409, { error: 'Cannot edit files while job is running' });
const err = await updateLocalFileContent(1, 'output', 'memo.md', 'x').catch(e => e);
expect(err).toBeInstanceOf(FileWriteError);
expect((err as FileWriteError).code).toBeUndefined();
expect((err as Error).message).toBe('Cannot edit files while job is running');
});
it('成功したら投げない', async () => {
mockFetch(200, { ok: true });
await expect(updateLocalFileContent(1, 'output', 'memo.md', 'x')).resolves.toBeUndefined();
});
});
+164
View File
@@ -0,0 +1,164 @@
/**
* 画面から Excel のセルを保存する口が、**実際に送り出す本文** (#1067 レビュー P2-1)。
*
* サーバは検査値を必須にしたので、欄名が 1 文字ずれた時点で画面からの保存は
* すべて 400 になる。部品側の配線テストは api の層ごと差し替えているため、
* 関数に渡した引数は見ていても、body に何が載るかは誰も見ていない。ここでは
* 通信そのものを差し替えて、URL・メソッド・**body の欄名と値**を固定する。
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { updateLocalXlsxCells, XlsxCellsSaveError } from './task-files';
/** 呼ばれた URL と fetch オプションを覚える stub。 */
function stubFetch(response: () => Response) {
const seen: { url: string; init?: RequestInit }[] = [];
vi.stubGlobal('fetch', (url: string, init?: RequestInit) => {
seen.push({ url, init });
return Promise.resolve(response());
});
return seen;
}
const json = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const SHA = 'a'.repeat(64);
afterEach(() => vi.unstubAllGlobals());
describe('updateLocalXlsxCells が送る本文', () => {
it('サーバが読む欄名で、区分・パス・編集・検査値を送る', async () => {
const seen = stubFetch(() => json({ ok: true, sheets: ['Sheet1'], cells: 1 }));
const result = await updateLocalXlsxCells(
7, 'output', 'reports/book.xlsx', [{ cell: 'B1', value: 42 }], SHA,
);
expect(result).toEqual({ ok: true, sheets: ['Sheet1'], cells: 1 });
expect(seen).toHaveLength(1);
expect(seen[0].url).toContain('/local/tasks/7/files/xlsx-cells');
expect(seen[0].init?.method).toBe('PUT');
expect((seen[0].init?.headers as Record<string, string>)['Content-Type']).toBe('application/json');
// サーバ側 (local-files-api の xlsx-cells) が読むのはこの欄名。ここがずれると
// 保存はすべて断られる。
expect(JSON.parse(String(seen[0].init?.body))).toEqual({
section: 'output',
path: 'reports/book.xlsx',
edits: [{ cell: 'B1', value: 42 }],
expected_source_sha256: SHA,
});
});
it('数式・シート名・値の消去もそのままの形で載る', async () => {
const seen = stubFetch(() => json({ ok: true, sheets: ['集計'], cells: 2 }));
await updateLocalXlsxCells(3, 'input', 'book.xlsx', [
{ sheet: '集計', cell: 'B2', formula: 'B1*2' },
{ sheet: '集計', cell: 'C2', value: null },
], SHA);
expect(JSON.parse(String(seen[0].init?.body)).edits).toEqual([
{ sheet: '集計', cell: 'B2', formula: 'B1*2' },
{ sheet: '集計', cell: 'C2', value: null },
]);
});
it('断られたら、理由のコードを添えて投げ直す(画面が翻訳して出せるように)', async () => {
stubFetch(() => json({ code: 'source_changed', error: 'The workbook changed since it was opened' }, 409));
await expect(updateLocalXlsxCells(7, 'output', 'book.xlsx', [{ cell: 'B1', value: 1 }], SHA))
.rejects.toMatchObject({ name: 'XlsxCellsSaveError', code: 'source_changed' });
});
/**
* コピーした式のおおもと(#1351 段 2)。理由だけ渡しても、画面は**どのセルが
* 原因か**を出せない。サーバが添えたセルと範囲をそのまま持ち上げる。
*/
it('おおもとのセルを断られたら、原因のセルと範囲も持ち上げる', async () => {
stubFetch(() => json({
code: 'shared_formula_translation',
error: 'Cannot rewrite the copied formula from C2 ...',
sheet: 'Sheet1',
cells: ['C2'],
ranges: ['C2:C4'],
}, 400));
await expect(updateLocalXlsxCells(7, 'output', 'book.xlsx', [{ cell: 'C2', value: 1 }], SHA))
.rejects.toMatchObject({
name: 'XlsxCellsSaveError',
code: 'shared_formula_translation',
cells: ['C2'],
ranges: ['C2:C4'],
});
});
it('本文が JSON でなくても投げ潰れない', async () => {
stubFetch(() => new Response('<html>502</html>', { status: 502 }));
const error = await updateLocalXlsxCells(7, 'output', 'book.xlsx', [{ cell: 'B1', value: 1 }], SHA)
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(XlsxCellsSaveError);
expect((error as XlsxCellsSaveError).code).toBeUndefined();
});
});
it('sends column-only edits using the server layout contract', async () => {
const seen = stubFetch(() => json({ sheets: ['S'], cells: 0, columns: 1 }));
await updateLocalXlsxCells(7, 'output', 'book.xlsx', [], SHA, [], [{ sheet: 'S', columns: 'B:C', width: 20 }]);
expect(JSON.parse(String(seen[0].init?.body))).toMatchObject({ edits: [], columns: [{ sheet: 'S', columns: 'B:C', width: 20 }], expected_source_sha256: SHA });
});
describe('列の挿入を送る(#1540 段 1)', () => {
it('構造の操作をそのままの欄名で載せる', async () => {
const seen = stubFetch(() => json({ ok: true, sheets: ['売上'], cells: 1, structural: 1 }));
const result = await updateLocalXlsxCells(
7, 'output', 'book.xlsx', [{ sheet: '売上', cell: 'C2', value: 9999 }], SHA, [], [],
[{ sheet: '売上', kind: 'insert-col', at: 2, count: 1 }],
);
expect(JSON.parse(String(seen[0].init?.body))).toEqual({
section: 'output',
path: 'book.xlsx',
edits: [{ sheet: '売上', cell: 'C2', value: 9999 }],
expected_source_sha256: SHA,
structural: [{ sheet: '売上', kind: 'insert-col', at: 2, count: 1 }],
});
// 当てた本数は返事に乗る。
expect(result.structural).toBe(1);
});
it('列を入れていないときは欄そのものを載せない', async () => {
const seen = stubFetch(() => json({ ok: true, sheets: ['売上'], cells: 1 }));
await updateLocalXlsxCells(7, 'output', 'book.xlsx', [{ cell: 'B1', value: 1 }], SHA, [], [], []);
expect(JSON.parse(String(seen[0].init?.body))).not.toHaveProperty('structural');
});
it('断られたときは理由と見つかったものを持ち帰る', async () => {
stubFetch(() => json({
error: 'Cannot insert columns on sheet "売上": linked_part',
code: 'structural_not_supported',
sheet: '売上',
reasons: ['linked_part'],
unsupportedElements: ['pivotTable'],
}, 409));
const err = await updateLocalXlsxCells(
7, 'output', 'book.xlsx', [], SHA, [], [],
[{ sheet: '売上', kind: 'insert-col', at: 2, count: 1 }],
).catch((e: unknown) => e);
expect(err).toBeInstanceOf(XlsxCellsSaveError);
const saveError = err as XlsxCellsSaveError;
expect(saveError.code).toBe('structural_not_supported');
expect(saveError.sheet).toBe('売上');
// 理由を落とすと、画面は「保存できません」としか言えなくなる。
expect(saveError.reasons).toEqual(['linked_part']);
expect(saveError.unsupportedElements).toEqual(['pivotTable']);
});
});
+42
View File
@@ -0,0 +1,42 @@
// createLocalTask の失敗経路。サーバの `code` を例外に載せて渡すところまで見る。
// ここが抜けると、画面側は日本語の文言に写せず、サーバの英語がそのまま出る。
import { describe, expect, it, vi, afterEach } from 'vitest';
import { createLocalTask } from './tasks';
afterEach(() => vi.unstubAllGlobals());
describe('createLocalTask error mapping', () => {
it('carries the server error code (and status) onto the thrown error', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(
JSON.stringify({ error: 'cannot create a task in this workspace', code: 'space_not_writable' }),
{ status: 403 },
)));
const err = await createLocalTask({ body: 'x', spaceId: 's-1' } as never).catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect((err as { code?: string }).code).toBe('space_not_writable');
expect((err as { status?: number }).status).toBe(403);
});
it('still carries the code when the same refusal comes back as 404', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(
JSON.stringify({ error: 'cannot create a task in this workspace', code: 'space_not_writable' }),
{ status: 404 },
)));
const err = await createLocalTask({ body: 'x', spaceId: 's-1' } as never).catch((e: unknown) => e);
expect((err as { code?: string }).code).toBe('space_not_writable');
expect((err as { status?: number }).status).toBe(404);
});
it('keeps the server message when the body has no code', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(
JSON.stringify({ error: 'body too long' }),
{ status: 400 },
)));
const err = await createLocalTask({ body: 'x' } as never).catch((e: unknown) => e);
expect((err as Error).message).toBe('body too long');
expect((err as { code?: string }).code).toBeUndefined();
});
});
+217 -12
View File
@@ -16,6 +16,12 @@ export interface SubtaskInfo {
worktreePath: string | null;
createdAt: string;
updatedAt: string;
/**
* `waiting_human` で止まっている理由(#1225)。`'captcha'` なら、検索が人かどうかの
* 確認に阻まれてサブタスクが止まっている。状態だけでは答え待ちと見分けられないので、
* 描き分けはこちらを見る。
*/
waitReason?: string | null;
children?: SubtaskInfo[];
childCount?: number;
childCompleted?: number;
@@ -47,11 +53,27 @@ export interface LocalTask {
workspacePath: string | null;
ownerId?: string | null;
ownerName?: string | null;
// #1513: 保存されている値をそのまま読むための型。チャット 1 件ごとの公開範囲は
// 選べなくなったので、送る側(作成・更新)にはもう無い。
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
visibilityScopeOrgName?: string | null;
/** Spaces foundation: which space this task belongs to (null = legacy/個人). */
spaceId?: string | null;
/**
* サーバが出した「この人はこのタスクのファイルを直せるか」(#1530)。
* 画面が区分(input/output)だけで編集の入口を決めると、共有ワークスペースの
* 閲覧だけのメンバーにも編集タブが見えて保存で断られる。判定はサーバ側の
* 書き込みゲートが唯一の出どころで、画面はここを読むだけにする。
* タスク詳細の口だけが返す(一覧には載らない)ので任意。
*/
canEdit?: boolean;
/**
* 同上。消せるか。第 2 段(#1530)で直せる人と同じ判定になった——消したものは
* ごみ箱へ退避されて戻せるので、狭く絞る理由が無くなった。旗は分けたまま置く
* (画面はこちらを読み、判定が再び分かれたときに追随できる)。
*/
canDelete?: boolean;
/**
* LLM 選択 Phase 1: sticky なワーカー直接指定。未設定 (null/undefined) は
* プロファイルベースの自動ルーティング。次にディスパッチされるジョブから
@@ -60,8 +82,20 @@ export interface LocalTask {
llmWorkerId?: string | null;
/** llmWorkerId とセットの場合のみ有効な reasoning effort。 */
llmEffort?: string | null;
/**
* タスク単位の実行オプション。`compactContext` はコンテキスト構成 (#1300)
* で、true = 圧縮(要約して渡す)、未設定/false = シンプル(前回のやり取りを
* そのまま流す・既定)。
*/
options?: {
compactContext?: boolean;
mcpDisabled?: boolean;
skillsDisabled?: boolean;
};
createdAt: string;
updatedAt: string;
/** True when this task has at least one historical ON policy snapshot. */
hasPolicyHistory?: boolean;
latestJob?: {
id: string;
status: string;
@@ -71,6 +105,7 @@ export interface LocalTask {
/** 現在の試行回数 (1 始まり)。>1 は中断→自動再試行が起きた印。 */
attempt?: number;
maxAttempts?: number;
nextRetryAt?: string | null;
/** 直近の中断理由コード (context_overflow / llm_error 等)。 */
abortReason?: string | null;
workerId?: string | null;
@@ -84,6 +119,20 @@ export interface LocalTask {
contextPromptTokens?: number | null;
contextLimitTokens?: number | null;
contextUpdatedAt?: string | null;
/** Reasoning effort in effect for this job (manual pin or router-selected). */
reasoningEffort?: string | null;
/**
* LLM Router: tier name the router selected for this job (e.g. 'cheap' /
* 'balanced' / 'deep'). Null when routing was skipped (manual pin, router
* disabled, or a non-'auto' profile). Populated on the detail fetch
* (`getLocalTask` → full job row); the reduced list-join always sends null.
*/
routerTier?: string | null;
/**
* issue #1099: 実行待ちのまま、引き受けられるワーカーが 1 台も居ない状態。
* 詳細取得でのみ算出される(一覧の縮小 join と実行中のジョブでは null)。
*/
noEligibleWorker?: boolean | null;
} | null;
subtasks?: SubtaskInfo[];
subtaskCount?: number;
@@ -92,8 +141,6 @@ export interface LocalTask {
feedbackTags?: string[] | null;
feedbackComment?: string | null;
feedbackAt?: string | null;
shareToken?: string | null;
sharedAt?: string | null;
missionBrief?: MissionBrief | null;
}
@@ -205,9 +252,137 @@ export async function decidePackageRequest(
}
}
export type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection';
// 別のワークスペースへの切り出し申請(#993 段階 3-C・設計 §6-4)。
export interface SpawnRequest {
id: string;
taskId: string | null;
jobId: string | null;
fromSpaceId: string | null;
/** 行き先を閲覧できない場合は null。 */
targetSpaceId: string | null;
/** 行き先の表示名。見えない相手のときは null(存在の有無を漏らさない)。 */
targetSpaceName: string | null;
ownerId: string | null;
title: string;
body: string;
pieceName: string;
profile: string;
status: 'pending' | 'approved' | 'denied' | 'auto_denied' | 'failed';
decidedBy: string | null;
resultTaskId: number | null;
failureReason: string | null;
createdAt: string;
decidedAt: string | null;
}
export async function fetchSpawnRequests(taskId: number): Promise<SpawnRequest[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/spawn-requests`);
if (!res.ok) throw new Error('failed to fetch spawn requests');
const data = await res.json();
return (data.spawnRequests ?? []) as SpawnRequest[];
}
export async function decideSpawnRequest(
taskId: number,
reqId: string,
decision: 'approve' | 'deny',
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/spawn-requests/${reqId}/decide`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export type PolicyApprovalStatus = 'pending' | 'approved' | 'denied' | 'consumed';
export interface PolicyApprovalRequest {
requestId: string;
jobId: string;
taskId: string;
pieceName: string;
movementName: string;
ruleId: string;
ruleVersion: number;
ruleName?: string;
toolName: string;
toolCategory: string;
safeSummary: string;
status: PolicyApprovalStatus;
expiresAt: string;
createdAt: string;
decidedAt: string | null;
}
export async function fetchPolicyApprovals(taskId: number): Promise<PolicyApprovalRequest[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/policy-approvals`);
if (!res.ok) throw Object.assign(new Error('failed to fetch policy approvals'), { status: res.status });
const data = await res.json() as { policyApprovals?: PolicyApprovalRequest[]; decidedHistoryLimit?: number };
return data.policyApprovals ?? [];
}
export async function decidePolicyApproval(
taskId: number,
requestId: string,
decision: 'approve' | 'deny',
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/policy-approvals/${encodeURIComponent(requestId)}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw Object.assign(new Error(body.error || res.statusText), { status: res.status });
}
}
export type PolicyEventAction =
| 'allow' | 'deny' | 'advise' | 'approval' | 'stop'
| 'approval_decision' | 'approval_expired' | 'approval_consumed';
export interface PolicyEvent {
eventId: string;
schemaVersion: 1;
decisionId: string;
taskId: string;
jobId: string;
policySnapshotId: string;
hookPoint: 'always' | 'tool.before' | 'completion.before_accept';
rule: { id: string; version: number; name?: string };
decisionMethod: 'deterministic' | 'model';
action: PolicyEventAction;
result: 'allowed' | 'blocked' | 'instruction_injected' | 'approval_requested'
| 'approved' | 'denied' | 'expired' | 'consumed' | 'stopped' | 'failed';
reasonCode: string;
reasonSummary: string;
evidenceRefs: string[];
sourceRefs?: Array<{ id: string; uri: string; title?: string }>;
tool?: { name: string; category: string };
approvalRequestId?: string;
decisionExecutionStatus: 'not_executed' | 'execution_allowed' | 'executed' | 'unknown';
model: { status: 'not_applicable' } | { status: 'observed'; provider: string; modelId: string };
confidence?: number;
actorId?: string;
createdAt: string;
}
export async function fetchPolicyEvents(taskId: number): Promise<PolicyEvent[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/policy-events`);
if (!res.ok) throw Object.assign(new Error('failed to fetch policy events'), { status: res.status });
const data = await res.json();
return (data.events ?? []) as PolicyEvent[];
}
export type CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection' | 'event';
export interface LocalTaskComment {
/** Structured final result from the server; absent on older comments. */
meta?: string | null;
id: number;
taskId: number;
author: string;
@@ -248,8 +423,7 @@ export interface CreateLocalTaskInput {
askPolicy: AskPolicy;
priority: Priority;
attachments?: Array<{ name: string; contentBase64: string }>;
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
grounded?: boolean;
browserSessionProfileId?: number | null;
/**
* Spaces foundation: 'persistent'(既定)はスペースのワークスペースに蓄積、
@@ -285,7 +459,14 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ ta
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create local task');
if (!res.ok) {
// サーバのエラーコードを例外に載せる。画面側はこれを見て日本語の文言に写す
// (メッセージ文字列の照合に頼ると、文言を直したとたんに壊れる)。
const err = new Error(data?.error ?? 'Failed to create local task') as Error & { code?: string; status?: number };
if (typeof data?.code === 'string') err.code = data.code;
err.status = res.status;
throw err;
}
return data;
}
@@ -339,9 +520,16 @@ export async function fetchMovementHistory(taskId: number): Promise<MovementHist
return data.events ?? [];
}
export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise<void> {
export async function postLocalTaskComment(
taskId: number,
body: string,
author: string = 'user',
attachments?: Array<{ name: string; contentBase64: string }>,
opts?: { grounded?: boolean },
): Promise<void> {
const payload: Record<string, unknown> = { body, author };
if (attachments && attachments.length > 0) payload.attachments = attachments;
if (opts?.grounded === true) payload.grounded = true;
const res = await fetch(`${BASE}/local/tasks/${taskId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -355,8 +543,6 @@ export async function updateLocalTask(
taskId: number,
updates: {
title?: string;
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
/**
* LLM 選択 Phase 1: sticky worker 直接指定。null で解除。effort を送るときは
* llmWorkerId も必ず一緒に送ること(サーバーは PATCH 済みフィールドだけ検証する
@@ -366,6 +552,11 @@ export async function updateLocalTask(
*/
llmWorkerId?: string | null;
llmEffort?: string | null;
/**
* タスク単位の実行オプションの部分更新 (#1300)。送った鍵だけがサーバー側で
* 併合される(他の鍵は消えない)。
*/
options?: { compactContext?: boolean };
},
): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
@@ -406,12 +597,26 @@ export async function deleteLocalTask(taskId: number): Promise<void> {
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete local task');
}
export async function cancelLocalTask(taskId: number): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/cancel`, {
export interface RetryJobSnapshot {
jobId: string;
attempt: number;
}
async function postTaskJobControl(taskId: number, action: 'cancel' | 'retry-now', snapshot?: RetryJobSnapshot): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/${action}`, {
method: 'POST',
...(snapshot ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(snapshot) } : {}),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to cancel task');
if (!res.ok) throw Object.assign(new Error(data?.error ?? 'Failed to update task'), { status: res.status });
}
export async function cancelLocalTask(taskId: number, snapshot?: RetryJobSnapshot): Promise<void> {
return postTaskJobControl(taskId, 'cancel', snapshot);
}
export async function retryLocalTaskNow(taskId: number, snapshot: RetryJobSnapshot): Promise<void> {
return postTaskJobControl(taskId, 'retry-now', snapshot);
}
export async function putFeedback(
+37
View File
@@ -0,0 +1,37 @@
import { BASE } from './client';
export type AudioSource = 'mic' | 'desktop';
export interface BlockTranslation {
text: string; sourceRevision: number; sourceText: string;
sourceLanguage: string; targetLanguage: string; model: string; configurationId: string;
}
export interface TranslationBlock { id: string; text: string; revision: number; translation?: BlockTranslation }
export interface TranslationConfig { configured: boolean; model: string; configurationId: string }
export interface TranslationRequest {
blockId: string; revision: number; text: string; sourceLanguage: string; targetLanguage: string; configurationId: string;
}
export interface TranslationResponse extends TranslationRequest { model: string }
export interface TranscriptEntry {
id: string; source: AudioSource; text: string; isFinal: boolean; receivedAt: string;
blocks?: TranslationBlock[];
segments?: { id?: string; start?: number | null; end?: number | null; speaker?: string | null; text: string; timestamp_source?: string }[];
}
export interface Recording { taskId?: number | null; id: string; title: string; entries: TranscriptEntry[]; createdAt: string; updatedAt: string }
export interface TranscriptionConfig { configured: boolean; finalizeTimeoutSeconds: number; translation?: TranslationConfig }
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${BASE}/transcription${path}`, { credentials: 'same-origin', ...init });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(typeof body.error === 'string' ? body.error : `文字起こし API エラー (${response.status})`);
}
return response.status === 204 ? undefined as T : response.json();
}
export const getTranscriptionConfig = () => request<TranscriptionConfig>('/config');
export const listRecordings = (taskId?: number) => request<{ recordings: Omit<Recording, 'entries'>[] }>(`/recordings${taskId == null ? '' : `?taskId=${taskId}`}`);
export const saveRecording = (recording: { taskId?: number; id?: string; title: string; entries: TranscriptEntry[] }) => request<{ recording: Recording; workspaceFiles?: { txt: string; json: string }; workspaceError?: string }>('/recordings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(recording) });
export const deleteRecording = (id: string) => request<void>(`/recordings/${encodeURIComponent(id)}`, { method: 'DELETE' });
export const getRecording = (id: string) => request<{ recording: Recording }>(`/recordings/${encodeURIComponent(id)}`);
export const translateBlock = (body: TranslationRequest, signal?: AbortSignal) => request<TranslationResponse>('/translate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal,
});
+160
View File
@@ -55,3 +55,163 @@ export async function getUsageDaily(params: {
if (!res.ok) throw new Error(`Failed to load usage (${res.status})`);
return res.json();
}
// ============================================================
// Admin prefix-cache views (GET /usage/llm-metrics).
//
// Mirror of the server types in src/bridge/usage-llm-metrics.ts. The rule those
// types encode carries over verbatim: `measuredTokens` is the ONLY denominator,
// and a bucket where it is 0 has NO hit rate — never a 0% one. Anything that
// could not be measured lives in `calls` and nowhere else.
/** The pair every hit rate is derived from. Measured calls only. */
export interface UsageCacheMeasuredTokens {
/** Prompt tokens over measured calls (the denominator). 0 ⇒ no hit rate. */
measuredTokens: number;
/** Reused prompt tokens over measured calls (the numerator). */
cachedTokens: number;
}
/** Detail-grain totals: knows how many of its calls were measurable. */
export interface UsageCacheTotals extends UsageCacheMeasuredTokens {
/** Every call in the bucket, measurable or not. */
calls: number;
/** Calls that reported both a prompt and a cached token count. */
measuredCalls: number;
}
/**
* Hour-ledger totals. The ledger sums tokens but keeps no measurable-call
* count, so this shape omits `measuredCalls` rather than inventing one.
*/
export interface UsageCacheLedgerTotals extends UsageCacheMeasuredTokens {
calls: number;
}
export type UsageCacheGapBucket = 'lt10s' | 'lte1m' | 'lte10m' | 'lte1h' | 'gt1h';
export type UsageCacheInterveningBucket = 'none' | 'few' | 'many';
export interface UsageLlmMetricsTrendPoint {
/** Viewer-local calendar day 'YYYY-MM-DD'. */
day: string;
/** Every call in the day. */
all: UsageCacheLedgerTotals;
/** First call of each resumed job — where a continuation pays or saves. */
continuationFirst: UsageCacheTotals;
}
export interface UsageLlmMetricsGapCell {
gap: UsageCacheGapBucket;
intervening: UsageCacheInterveningBucket;
totals: UsageCacheTotals;
}
/**
* One viewer-local day of a single comparison group — the card sparkline's
* input. Mirror of `ComparisonTrendPoint` in src/bridge/usage-llm-metrics.ts.
*
* `measuredTokens` is the only denominator here too: a day that carries calls
* but no measured tokens has NO rate, and the line shows a hole.
*/
export interface UsageLlmMetricsComparisonTrendPoint {
/** Viewer-local calendar day 'YYYY-MM-DD'. */
day: string;
totals: UsageCacheLedgerTotals;
}
export interface UsageLlmMetricsComparisonRow {
workerId: string | null;
cacheKey: string | null;
model: string | null;
totals: UsageCacheTotals;
/** Comparable across groups; raw prefill ms is not. null when unknown. */
uncachedTokensPerSec: number | null;
prefillP95Ms: number | null;
/**
* Generation speed, weighted by how much each call generated. null for any
* period recorded before the worker measurements were collected — that
* cannot be backfilled, so those rows stay empty rather than showing 0.
*/
decodeTokensPerSec: number | null;
/** 5th percentile generation speed — the slow tail. p5, because on a speed
* the bad end is the bottom one. null when unknown. */
decodeTokensPerSecP5: number | null;
/**
* This group's own days, sparse. The page-wide `trend` sums every backend,
* so it cannot say whether THIS one is losing its cache.
*/
trend: UsageLlmMetricsComparisonTrendPoint[];
distinctJobs: number;
contextLimitTokens: number | null;
}
export interface UsageLlmMetricsPurposeRow {
purpose: string;
totals: UsageCacheTotals;
completionTokens: number;
/**
* Calls that reported a completion-token count. `completionTokens` is a sum
* over these only, so 0 calls means "nobody reported it" — render a dash,
* not a zero.
*/
completionCalls: number;
prefillMsSum: number;
prefillCalls: number;
}
export interface UsageLlmMetricsResponse {
/** Requested range. */
from: string;
to: string;
/** Range actually aggregated — later than `from` when the scan was capped. */
effectiveFrom: string;
tzOffset: number;
/** True when the range was narrowed from the old end to fit the scan cap. */
truncated: boolean;
/** Detail rows in the REQUESTED range (may exceed `scanCap`). */
totalRows: number;
scanCap: number;
/** Calls whose job row is gone — invisible to the continuation view. */
callsWithoutJob: number;
/**
* Calls that belong to no task at all (every Gitea-issue job). Also
* invisible to the continuation view, so a continuation series reading 0
* has to be qualified by this rather than taken at face value.
*/
callsWithoutTask: number;
scannedRows: number;
gapRows: number;
trend: UsageLlmMetricsTrendPoint[];
/** Always all 15 gap × intervening combinations, zero-filled, fixed order. */
gapDecay: UsageLlmMetricsGapCell[];
/** Sorted by calls desc. */
comparison: UsageLlmMetricsComparisonRow[];
/** Sorted by calls desc. */
byPurpose: UsageLlmMetricsPurposeRow[];
/**
* Calls left out of the gap view because their previous call happened before
* the scan window, so how much traffic ran in between is unknown.
*/
interveningUnknownRows: number;
}
/**
* Admin-only prefix-cache metrics. Returns null on 403 (a signed-in non-admin)
* so the caller can drop the whole section instead of rendering an error the
* viewer can do nothing about. Every other failure throws.
*/
export async function getUsageLlmMetrics(params: {
from?: string;
to?: string;
tzOffset?: number;
}): Promise<UsageLlmMetricsResponse | null> {
const qs = new URLSearchParams();
if (params.from) qs.set('from', params.from);
if (params.to) qs.set('to', params.to);
if (params.tzOffset !== undefined) qs.set('tzOffset', String(params.tzOffset));
const q = qs.toString();
const res = await fetch(`${BASE}/usage/llm-metrics${q ? `?${q}` : ''}`);
if (res.status === 403) return null;
if (!res.ok) throw new Error(`Failed to load LLM cache metrics (${res.status})`);
return res.json();
}
+88
View File
@@ -1,3 +1,4 @@
import type { WorkerCapabilities } from '@aao/answer-worker-contract';
// api.ts から分割(挙動不変): ワーカー・バックエンド・ノード状態(Side Info Panel)。
export interface WorkerInfo {
@@ -52,6 +53,7 @@ export interface LlmWorkerListItem {
roles: string[];
reasoningEfforts?: string[];
vlm?: boolean;
capabilities?: WorkerCapabilities;
enabled?: boolean;
}
@@ -82,6 +84,20 @@ export interface NodeStatus {
totalSlots: number;
loadedModel: string | null;
throughputTps: number | null;
/**
* 速さとキャッシュの内訳。`null` は**測れなかった**という意味で、0 とは
* 別の事実。累積カウンターの差分から出す速さは、サーバー側が観測を始めた
* 直後の 1 回目は必ず `null` になる(基準を取っているだけ)。
*
* 省略(undefined)は「この行を作った経路がそもそもこの値を扱わない」。
* どちらも 0 に潰さないこと。
*/
prefillTps?: number | null;
decodeTps?: number | null;
/** prefix cache のヒット率。0–1 の割合(百分率ではない)。 */
cacheHitRate?: number | null;
/** KV cache の占有率。0–1 の割合(1 が満杯)。 */
kvCacheUsage?: number | null;
lastSeen: string;
lastProbeError?: string;
}
@@ -92,6 +108,11 @@ export interface WorkerStatusBackendRow {
busySlots: number;
totalSlots: number;
online: boolean | null;
/**
* 直近の probe が失敗した理由(1 行)。管理者にだけサーバーが載せるので、
* 一般利用者は常に undefined。宛先の URL は含まない(サーバー側で伏せ済み)。
*/
probeError?: string;
}
export interface WorkerStatusRow {
@@ -105,6 +126,8 @@ export interface WorkerStatusRow {
busySlots?: number;
totalSlots?: number;
online?: boolean;
/** Model reported as loaded by the live worker probe, when available. */
loadedModel?: string | null;
/** Per-backend rows for proxy workers (Phase 3c + dashboard tree). */
backends?: WorkerStatusBackendRow[];
/**
@@ -114,6 +137,26 @@ export interface WorkerStatusRow {
* clients always receive undefined.
*/
occupants?: Array<{ user: string; kind: string }>;
/**
* 直近の probe が失敗した理由(1 行)。`occupants` と同じく管理者にだけ
* サーバーが載せる。オフラインの表示の隣に出す用(issue #1128)。
*/
probeError?: string;
/**
* ゲートウェイは応答したが、配下の一覧を読み取れなかった(issue #1128)。
* 「配下が 0 台」との区別に使う。管理者以外にも届く。
*/
backendsUnknown?: boolean;
/**
* 設定したモデルで呼び出せないので、この台はジョブを取らない
* (issue #1231)。`probeError` と同じく管理者にだけサーバーが載せる。
*
* - `not_served` — 接続先がその名前を配っていない。
* - `ambiguous` — 綴り違いの候補が 2 つ以上あり、どれのことか決められない。
*
* 接続先が配っているモデル名の一覧はここには来ない(理由の区別だけ)。
*/
modelServingIssue?: 'not_served' | 'ambiguous';
}
export async function fetchWorkerStatuses(): Promise<WorkerStatusRow[]> {
@@ -142,3 +185,48 @@ export async function fetchNodeStatus(): Promise<NodeStatus[]> {
}
return (await res.json()).nodes;
}
// ── 用途別の対応状況(issue #1486) ──────────────────────────────────────────
/** 画面に並べる用途。サーバーの `WORKER_PURPOSES` と同じ顔ぶれ・同じ順。 */
export type WorkerPurposeId = 'dictionary' | 'document_question' | 'vision' | 'task';
/**
* いま使えるか。**対応しているかとは別の軸**で、混ぜてはいけない。
* `busy` を「非対応」と書かないための分け方(issue #1486)。
*/
export type WorkerPurposeAvailability = 'free' | 'busy' | 'offline' | 'unknown';
export interface WorkerPurposeWorkerRow {
workerId: string;
/** その用途の宛先になれるか。サーバー側の本物の判定が出した答え。 */
supported: boolean;
/** 対応している候補のうち、優先か代替か。区別が無い用途では付かない。 */
preference?: 'primary' | 'fallback';
/** 非対応の理由(不足している設定項目の名前)。 */
missingFields: string[];
availability: WorkerPurposeAvailability;
/** 接続できない理由。管理者にだけサーバーが載せる。 */
reason?: string;
}
export interface WorkerPurposeRow {
purpose: WorkerPurposeId;
supported: boolean;
availability: WorkerPurposeAvailability;
/** 1 台も対応していないときの理由コード。 */
code?: string;
workers: WorkerPurposeWorkerRow[];
}
/**
* GET /api/local/dashboard/worker-purposes — 用途ごとに使えるワーカー。
*
* **画面側でこの答えを作り直さないこと。**選び方(役割の優先順・`vlm` の要否・
* claim 条件)はサーバーの本物の判定が持っていて、ここはその結果を運ぶだけ。
*/
export async function fetchWorkerPurposes(): Promise<WorkerPurposeRow[]> {
const res = await fetch('/api/local/dashboard/worker-purposes', { credentials: 'include' });
if (!res.ok) throw new Error(`Failed to list worker purposes: ${res.status}`);
return (await res.json()).purposes;
}
@@ -1,4 +1,5 @@
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityEvent, activityKindLabel, activityEventTitle, formatActivityMeta, formatActivityTimestamp } from '../../lib/utils';
import { MarkdownText } from '../../lib/markdown-text';
@@ -17,12 +18,30 @@ const KIND_COLORS: Record<string, { dot: string; badge: string; badgeText: strin
final: { dot: 'bg-green-600', badge: 'bg-green-100 dark:bg-green-500/15', badgeText: 'text-green-700 dark:text-green-300', border: 'border-green-200 dark:border-green-500/30' },
ask: { dot: 'bg-amber-500', badge: 'bg-amber-100 dark:bg-amber-500/15', badgeText: 'text-amber-700 dark:text-amber-300', border: 'border-amber-200 dark:border-amber-500/30' },
preflight: { dot: 'bg-purple-400', badge: 'bg-purple-50 dark:bg-purple-500/15', badgeText: 'text-purple-600 dark:text-purple-300', border: 'border-purple-100 dark:border-purple-500/30' },
// 画像が渡らなかったことは「読んだつもりで読めていない」に直結するので、
// 他のログに埋もれない色を当てる(#1105)。
image_omitted: { dot: 'bg-amber-600', badge: 'bg-amber-100 dark:bg-amber-500/15', badgeText: 'text-amber-800 dark:text-amber-300', border: 'border-amber-300 dark:border-amber-500/40' },
other: { dot: 'bg-slate-400', badge: 'bg-slate-100', badgeText: 'text-slate-600', border: 'border-slate-200' },
};
export const ActivityEventCard = memo(function ActivityEventCard({ event, isLast }: { event: ActivityEvent; isLast?: boolean }) {
const { t } = useTranslation('detail');
const colors = KIND_COLORS[event.kind] ?? KIND_COLORS.other!;
const meta = formatActivityMeta(event.workerId, event.mode, event.backendId);
const omission = event.kind === 'image_omitted' ? event.imageOmission : undefined;
// 記録の行は機械可読の英語のまま。読ませる文はここで組む。
const title = omission
? t('progress.imageOmitted.title', { n: omission.count })
: activityEventTitle(event);
const note = omission
? [
t(`progress.imageOmitted.${omission.reason === 'over_budget' ? 'overBudget' : omission.reason === 'vlm_disabled' ? 'vlmDisabled' : 'unknown'}`, {
estimated: omission.estimatedTokens?.toLocaleString() ?? '?',
budget: omission.budgetTokens?.toLocaleString() ?? '?',
}),
omission.files ? t('progress.imageOmitted.files', { files: omission.files }) : '',
].filter(Boolean).join('\n')
: event.note;
return (
<div className="grid gap-3" style={{ gridTemplateColumns: '16px minmax(0,1fr)' }}>
@@ -36,7 +55,7 @@ export const ActivityEventCard = memo(function ActivityEventCard({ event, isLast
{activityKindLabel(event.kind)}
</span>
<span className="text-[13px] font-bold text-slate-900 min-w-0 truncate flex-1">
{activityEventTitle(event)}
{title}
</span>
{event.timestamp && (
<span className="text-2xs text-slate-500 flex-shrink-0">
@@ -44,14 +63,14 @@ export const ActivityEventCard = memo(function ActivityEventCard({ event, isLast
</span>
)}
</div>
{event.note && (
{note && (
MD_KINDS.has(event.kind) ? (
<div className="mt-1.5">
<MarkdownText text={event.note} />
<MarkdownText text={note} />
</div>
) : (
<div className="mt-1.5 text-xs text-slate-600 whitespace-pre-wrap break-words leading-relaxed">
{event.note}
{note}
</div>
)
)}
+3 -3
View File
@@ -32,7 +32,7 @@ function Actions({ onClose, busy, submitLabel }: { onClose: () => void; busy: bo
<button type="button" onClick={onClose} className="px-3 h-8 rounded-md text-xs font-medium border border-hairline text-slate-700 hover:bg-surface">
{t('cancel')}
</button>
<button type="submit" disabled={busy} className="px-3 h-8 rounded-md text-xs font-semibold bg-accent text-white disabled:opacity-50 hover:opacity-90">
<button type="submit" disabled={busy} className="px-3 h-8 rounded-md text-xs font-semibold bg-accent text-accent-fg disabled:opacity-50 hover:opacity-90">
{busy ? '...' : submitLabel}
</button>
</div>
@@ -80,8 +80,8 @@ export function CreateLocalUserDialog({ onClose, onCreated }: { onClose: () => v
</Field>
<Field label={t('create.role')}>
<select value={role} onChange={e => setRole(e.target.value as 'user' | 'admin')} className={inputCls}>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">{t('admin:users.role.user')}</option>
<option value="admin">{t('admin:users.role.admin')}</option>
</select>
</Field>
<p className="text-2xs text-slate-500 mb-3">{t('create.note')}</p>
@@ -79,18 +79,18 @@ export function BrowserSessionPanel() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Browser Sessions</h3>
<h3 className="text-sm font-semibold">{t('panel.title')}</h3>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
className="px-3 py-1 text-xs bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50"
>
New Session
{t('panel.newSession')}
</button>
</div>
{sessions.length === 0 && (
<p className="text-xs text-gray-400">No active sessions</p>
<p className="text-xs text-gray-400">{t('panel.empty')}</p>
)}
{sessions.map(session => (
@@ -111,21 +111,21 @@ export function BrowserSessionPanel() {
onClick={() => setActiveSessionId(session.id)}
className="px-2 py-1 text-xs bg-slate-100 rounded hover:bg-slate-200"
>
View
{t('panel.view')}
</button>
{session.state === 'user_interactive' && (
<button
onClick={() => releaseMutation.mutate(session.id)}
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700"
>
Done
{t('panel.done')}
</button>
)}
<button
onClick={() => deleteMutation.mutate(session.id)}
className="px-2 py-1 text-xs bg-red-100 dark:bg-red-500/15 text-red-700 dark:text-red-300 rounded hover:bg-red-200 dark:hover:bg-red-500/25"
>
Destroy
{t('panel.destroy')}
</button>
</div>
</div>
@@ -147,11 +147,15 @@ export function BrowserSessionPanel() {
{t('session.pipActive')}
</div>
) : (
// クリップボードの許可は付けない (issue #1195)。ただし**外しても権限は残る**
// ——同じ出どころの枠は、書かなくても既定でクリップボードの権限を持つ。
// 栓は配信側にある(x11vnc を `-nosel` で起こす。browser-session.ts)。
// ここを空けてあるのは、読む人が「クリップボードを使う意図がある」と
// 誤解しないため。
<iframe
src={activeSession.novncPath}
className="w-full h-[500px] border-0"
title="Browser Session"
allow="clipboard-read; clipboard-write"
title={t('panel.frameTitle')}
/>
)}
</div>
+256
View File
@@ -0,0 +1,256 @@
// @vitest-environment jsdom
/**
* AskUser の質問カード (issue #985)。
*
* kind='ask' コメントの本文が {type:'ask_questions'} JSON なら、選択肢を
* タップで答えられるカードを描画する。回答は既存のコメント送信 API に載せる
* (waiting_human のジョブはコメント投稿で再開する既存経路をそのまま使う)。
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTranslation: () => ({ t: (key: string, opts?: Record<string, unknown>) => (opts?.defaultValue as string) ?? key }),
}));
vi.mock('mermaid', () => ({ default: { initialize: vi.fn(), run: vi.fn().mockResolvedValue(undefined) } }));
vi.mock('../embed/EmbedBlock', () => ({ EmbedBlock: () => null }));
// 実 API (postLocalTaskComment) と同じ引数で受ける。何でも受け取る形にすると
// シグネチャが変わってもテストが気づかない。
//
// vi.mock のファクトリは実モジュールの型と突き合わされないので、差し替え関数に
// satisfies を付けて「実シグネチャと同じ形」をコンパイラに確かめさせる。
// これが無いと、引数が増えたときに黙って落とすだけのモックになる。
type PostComment = typeof import('../../api/tasks').postLocalTaskComment;
const postComment = vi.fn<PostComment>(async () => {});
vi.mock('../../api/tasks', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
postLocalTaskComment: ((...args) => postComment(...args)) satisfies PostComment,
}));
import { AskUserCard, tryParseAskQuestions, findInteractiveAskId } from './AskUserCard';
const PAYLOAD = JSON.stringify({
type: 'ask_questions',
questions: [
{
question: 'デプロイ先はどちらにしますか?',
header: 'デプロイ先',
options: [
{ label: '本番', description: '即時反映' },
{ label: 'ステージング', description: '検証後に昇格' },
],
},
],
});
beforeEach(() => postComment.mockClear());
describe('tryParseAskQuestions', () => {
it('ask_questions JSON を読む', () => {
expect(tryParseAskQuestions(PAYLOAD)?.questions).toHaveLength(1);
});
it('自由文・別種の JSON は null', () => {
expect(tryParseAskQuestions('❓ 確認事項\n\nどうしますか?')).toBeNull();
expect(tryParseAskQuestions(JSON.stringify({ type: 'thinking', text: 'x' }))).toBeNull();
});
it('questions が空なら null(壊れたペイロードをカード化しない)', () => {
expect(tryParseAskQuestions(JSON.stringify({ type: 'ask_questions', questions: [] }))).toBeNull();
});
});
describe('AskUserCard', () => {
const parsed = () => tryParseAskQuestions(PAYLOAD)!;
it('質問・選択肢・補足を描画する', () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
expect(screen.getByText('デプロイ先はどちらにしますか?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /本番/ })).toBeInTheDocument();
expect(screen.getByText('検証後に昇格')).toBeInTheDocument();
});
it('選択して送信すると、回答テキストがコメントとして投稿される', async () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
fireEvent.click(screen.getByRole('button', { name: /本番/ }));
fireEvent.click(screen.getByRole('button', { name: 'askCard.submit' }));
await waitFor(() => expect(postComment).toHaveBeenCalledTimes(1));
const [taskId, body] = postComment.mock.calls[0];
expect(taskId).toBe(7);
expect(body).toContain('デプロイ先');
expect(body).toContain('本番');
});
it('自由記述(その他)で答えられる', async () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
fireEvent.click(screen.getByRole('button', { name: /askCard.other/ }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'カナリアで 10% だけ' } });
fireEvent.click(screen.getByRole('button', { name: 'askCard.submit' }));
await waitFor(() => expect(postComment).toHaveBeenCalled());
expect(postComment.mock.calls[0][1]).toContain('カナリアで 10% だけ');
});
it('未回答の質問があるうちは送信できない', () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
expect(screen.getByRole('button', { name: 'askCard.submit' })).toBeDisabled();
});
it('multiSelect は複数選べて、両方が回答に載る', async () => {
const multi = tryParseAskQuestions(JSON.stringify({
type: 'ask_questions',
questions: [{
question: '対象は?',
multiSelect: true,
options: [{ label: 'API' }, { label: 'UI' }, { label: 'docs' }],
}],
}))!;
render(<AskUserCard taskId={7} payload={multi} />);
fireEvent.click(screen.getByRole('button', { name: /API/ }));
fireEvent.click(screen.getByRole('button', { name: /UI/ }));
fireEvent.click(screen.getByRole('button', { name: 'askCard.submit' }));
await waitFor(() => expect(postComment).toHaveBeenCalled());
const body = postComment.mock.calls[0][1];
expect(body).toContain('API');
expect(body).toContain('UI');
});
it('送信後はボタンが無効になる(二重送信の防止)', async () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
fireEvent.click(screen.getByRole('button', { name: /本番/ }));
fireEvent.click(screen.getByRole('button', { name: 'askCard.submit' }));
await waitFor(() => expect(postComment).toHaveBeenCalled());
expect(screen.getByRole('button', { name: 'askCard.submitted' })).toBeDisabled();
});
});
/**
* stale カード対策: 回答済み・タスク非停車中の履歴カードは読み取り専用にする。
* phase はローカル state なのでリロードで 'idle' に戻り、放置すると
* 完了済みタスクの古いカードをワンタップで誤爆再実行できてしまう。
*/
describe('AskUserCard readOnly', () => {
const parsed = () => tryParseAskQuestions(PAYLOAD)!;
it('選択肢は見えるが押せず、送信ボタンは出さない', () => {
render(<AskUserCard taskId={7} payload={parsed()} readOnly />);
expect(screen.getByRole('button', { name: /本番/ })).toBeDisabled();
expect(screen.queryByRole('button', { name: 'askCard.submit' })).toBeNull();
expect(screen.getByText('askCard.closed')).toBeInTheDocument();
});
});
describe('findInteractiveAskId', () => {
const c = (id: number, author: string, kind: string) =>
({ id, taskId: 7, author, kind, body: 'x', createdAt: '', attachments: [] }) as never;
it('waiting_human で未回答の最新 ask だけが対話可能', () => {
const comments = [c(1, 'user', 'request'), c(2, 'agent', 'ask')];
expect(findInteractiveAskId(comments, 'waiting_human')).toBe(2);
});
it('ask より後にユーザーコメントがあれば回答済みとして null', () => {
const comments = [c(1, 'user', 'request'), c(2, 'agent', 'ask'), c(3, 'user', 'comment')];
expect(findInteractiveAskId(comments, 'waiting_human')).toBeNull();
});
it('タスクが waiting_human でなければ null(完了・実行中は受付終了)', () => {
const comments = [c(1, 'user', 'request'), c(2, 'agent', 'ask')];
expect(findInteractiveAskId(comments, 'succeeded')).toBeNull();
expect(findInteractiveAskId(comments, 'running')).toBeNull();
expect(findInteractiveAskId(comments, undefined)).toBeNull();
});
});
/**
* 配線テスト: ChatMessage が kind='ask' の構造化コメントからカードを実際に
* 出すこと。カード単体テストだけだと、ChatMessage 側の分岐を消しても
* 誰も気づかない(推論表示の実装で実際に踏んだ穴)。
*/
describe('ChatMessage への配線', () => {
it('構造化 ask コメントでカードが描画される', async () => {
const { ChatMessage } = await import('./ChatMessage');
const comment = {
id: 1, taskId: 7, author: 'agent', kind: 'ask', body: PAYLOAD,
createdAt: '2026-07-30T00:00:00Z', attachments: [],
} as never;
render(<ChatMessage comment={comment} taskId={7} />);
expect(screen.getByRole('button', { name: /本番/ })).toBeInTheDocument();
});
it('既定は読み取り専用(明示的に askInteractive を渡した場所だけ回答できる)', async () => {
const { ChatMessage } = await import('./ChatMessage');
const comment = {
id: 3, taskId: 7, author: 'agent', kind: 'ask', body: PAYLOAD,
createdAt: '2026-07-30T00:00:00Z', attachments: [],
} as never;
const { rerender } = render(<ChatMessage comment={comment} taskId={7} />);
expect(screen.queryByRole('button', { name: 'askCard.submit' })).toBeNull();
rerender(<ChatMessage comment={comment} taskId={7} askInteractive />);
expect(screen.getByRole('button', { name: 'askCard.submit' })).toBeInTheDocument();
});
/**
* issue #1197: 質問文が Markdown のまま素で出ていた。カード単体ではなく
* ChatMessage 経由で見る — 「カードは整形するが ChatMessage が別物を出す」
* 穴を塞ぐため。
*/
it('質問文の Markdown が整形されて出る(強調・箇条書き)', async () => {
const { ChatMessage } = await import('./ChatMessage');
const body = JSON.stringify({
type: 'ask_questions',
questions: [{
question: '**どちら**を優先しますか?\n\n- 速さ\n- 正確さ',
options: [{ label: '速さ' }, { label: '正確さ' }],
}],
});
const comment = {
id: 11, taskId: 7, author: 'agent', kind: 'ask', body,
createdAt: '2026-08-19T00:00:00Z', attachments: [],
} as never;
const { container } = render(<ChatMessage comment={comment} taskId={7} />);
expect(container.querySelector('strong')?.textContent).toBe('どちら');
expect(container.querySelectorAll('li')).toHaveLength(2);
// 生の記号が残っていたら整形されていない。
expect(container.textContent).not.toContain('**');
expect(container.textContent).not.toContain('- 速さ');
});
/**
* 質問文はモデルが書いた文字列。整形に相乗りしたぶん、生 HTML の扱いも
* 他の吹き出し(MarkdownText の renderer.html + DOMPurify)と同じでなければ
* ならない。ここだけ緩めない。
*/
it('質問文に混ざった生 HTML は生きた DOM にしない', async () => {
const { ChatMessage } = await import('./ChatMessage');
const body = JSON.stringify({
type: 'ask_questions',
questions: [{
question: '確認です <img src=x onerror="alert(1)"> <script>alert(2)</script>',
options: [{ label: 'はい' }],
}],
});
const comment = {
id: 12, taskId: 7, author: 'agent', kind: 'ask', body,
createdAt: '2026-08-19T00:00:00Z', attachments: [],
} as never;
const { container } = render(<ChatMessage comment={comment} taskId={7} />);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('script')).toBeNull();
// イベントハンドラ属性が生きた要素に付いていないこと(文字列として本文に
// 見えているのは正しい姿なので、innerHTML の文字列検索では見ない)。
expect(container.querySelector('[onerror]')).toBeNull();
// 読めるソースとして残っていること(黙って消えるのではなく見える)。
expect(container.textContent).toContain('<img');
});
it('自由文 ask コメントは従来の吹き出しのまま', async () => {
const { ChatMessage } = await import('./ChatMessage');
const comment = {
id: 2, taskId: 7, author: 'agent', kind: 'ask', body: '❓ 確認事項\n\nどうしますか?',
createdAt: '2026-07-30T00:00:00Z', attachments: [],
} as never;
render(<ChatMessage comment={comment} taskId={7} />);
expect(screen.getByText(/どうしますか/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'askCard.submit' })).toBeNull();
});
});
+209
View File
@@ -0,0 +1,209 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { postLocalTaskComment } from '../../api/tasks';
import { MarkdownText } from '../../lib/markdown-text';
/**
* AskUser の質問カード (issue #985)。
*
* kind='ask' コメントの本文が {type:'ask_questions'} JSON のとき、ChatMessage が
* このカードを描画する。選択(または自由記述)をまとめ、既存のコメント送信 API に
* 載せる — waiting_human のジョブはユーザーコメントで再開する既存経路をそのまま
* 使うため、専用の回答 API は無い。回答テキストは「見出し: 選択」の平文で組み、
* 会話履歴としてもエージェントの入力としてもそのまま読める形にする。
*/
export interface AskCardOption {
label: string;
description?: string;
}
export interface AskCardQuestion {
question: string;
header?: string;
options: AskCardOption[];
multiSelect?: boolean;
}
export interface AskQuestionsPayload {
type: 'ask_questions';
questions: AskCardQuestion[];
}
const OTHER = '__other__';
/** groupCommentsByMovement と同じユーザーコメント判定。 */
const isUserComment = (c: { author: string; kind: string }) =>
c.kind === 'request' || c.kind === 'comment' || c.kind === 'interjection';
/**
* いま回答を受け付けている ask コメントの id を返す(無ければ null)。
* 条件: ジョブが waiting_human で、最後の ask より後にユーザーコメントが無いこと。
* それ以外の ask カード(回答済み・タスク完了後の履歴)は読み取り専用で描画する —
* カードの phase はローカル state なのでリロードで戻り、放置すると古いカードの
* ワンタップで意図しない再実行が起こせてしまう。
*/
export function findInteractiveAskId(
comments: ReadonlyArray<{ id: number; author: string; kind: string }>,
jobStatus: string | null | undefined,
): number | null {
if (jobStatus !== 'waiting_human') return null;
for (let i = comments.length - 1; i >= 0; i--) {
const c = comments[i]!;
if (isUserComment(c)) return null;
if (c.kind === 'ask') return c.id;
}
return null;
}
export function tryParseAskQuestions(body: string): AskQuestionsPayload | null {
if (!body.startsWith('{')) return null;
try {
const data = JSON.parse(body) as AskQuestionsPayload;
if (data?.type !== 'ask_questions' || !Array.isArray(data.questions) || data.questions.length === 0) return null;
if (!data.questions.every(q => typeof q?.question === 'string' && Array.isArray(q.options) && q.options.length > 0)) return null;
return data;
} catch {
return null;
}
}
interface QuestionState {
selected: Set<string>;
otherText: string;
}
export function AskUserCard({ taskId, commentId, payload, readOnly = false }: { taskId: number; commentId?: number; payload: AskQuestionsPayload; readOnly?: boolean }) {
const { t } = useTranslation('chat');
const [states, setStates] = useState<QuestionState[]>(
() => payload.questions.map(() => ({ selected: new Set<string>(), otherText: '' })),
);
const [phase, setPhase] = useState<'idle' | 'sending' | 'done' | 'error'>('idle');
const toggle = (qi: number, label: string, multi: boolean | undefined) => {
setStates(prev => prev.map((s, i) => {
if (i !== qi) return s;
const selected = new Set(multi ? s.selected : []);
if (s.selected.has(label) && multi) selected.delete(label);
else selected.add(label);
return { ...s, selected };
}));
};
const answered = (qi: number): boolean => {
const s = states[qi]!;
if (s.selected.has(OTHER)) return s.otherText.trim().length > 0;
return s.selected.size > 0;
};
const allAnswered = payload.questions.every((_, qi) => answered(qi));
const submit = async () => {
if (phase === 'sending' || phase === 'done') return;
setPhase('sending');
const lines = payload.questions.map((q, qi) => {
const s = states[qi]!;
const picks = [...s.selected].filter(l => l !== OTHER);
if (s.selected.has(OTHER)) picks.push(s.otherText.trim());
return `${q.header ?? q.question}: ${picks.join(', ')}`;
});
try {
await postLocalTaskComment(taskId, lines.join('\n'));
setPhase('done');
} catch {
setPhase('error');
}
};
const disabled = readOnly || phase === 'sending' || phase === 'done';
return (
<div className="flex justify-start">
<div className="max-w-[82%] min-w-[280px] bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm space-y-3">
{payload.questions.map((q, qi) => (
<div key={qi}>
{q.header ? (
<div className="text-2xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">{q.header}</div>
) : null}
{/* 質問文はエージェントが書く地の文で、箇条書き・強調・コード片が普通に
入ってくる (issue #1197)。自由文の ask 吹き出し・結果バブルと同じ
MarkdownText に相乗りする — 生 HTML のエスケープと DOMPurify も
そこで揃うので、ここだけ緩くならない。
選択肢のラベルと補足 (下の <button> の中) は素のままにしてある。
MarkdownText は <div>/<p> を吐くので、phrasing content しか置けない
<button> の中には入れられない。 */}
<div
data-dictionary-eligible={commentId === undefined ? undefined : `message-${commentId}-question-${qi}`}
className="mt-0.5 text-slate-900 dark:text-slate-100"
>
<MarkdownText text={q.question} />
</div>
<div className="mt-2 flex flex-col gap-1.5" role="group" aria-label={q.header ?? q.question}>
{q.options.map(option => {
const active = states[qi]!.selected.has(option.label);
return (
<button
key={option.label}
type="button"
disabled={disabled}
aria-pressed={active}
onClick={() => toggle(qi, option.label, q.multiSelect)}
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors disabled:opacity-60 ${
active
? 'border-accent bg-accent/10 text-slate-900 dark:text-slate-100'
: 'border-amber-200 dark:border-amber-500/30 bg-white/70 dark:bg-slate-900/30 text-slate-700 dark:text-slate-200 hover:bg-white'
}`}
>
<span className="font-medium">{option.label}</span>
{option.description ? (
<span className="block text-2xs text-slate-500 dark:text-slate-400">{option.description}</span>
) : null}
</button>
);
})}
<button
type="button"
disabled={disabled}
aria-pressed={states[qi]!.selected.has(OTHER)}
onClick={() => toggle(qi, OTHER, q.multiSelect)}
className={`rounded-lg border border-dashed px-3 py-2 text-left text-sm transition-colors disabled:opacity-60 ${
states[qi]!.selected.has(OTHER)
? 'border-accent bg-accent/10 text-slate-900 dark:text-slate-100'
: 'border-amber-300 dark:border-amber-500/40 text-slate-500 dark:text-slate-400 hover:bg-white/60'
}`}
>
{t('askCard.other')}
</button>
{states[qi]!.selected.has(OTHER) ? (
<input
type="text"
disabled={disabled}
value={states[qi]!.otherText}
onChange={e => setStates(prev => prev.map((s, i) => (i === qi ? { ...s, otherText: e.target.value } : s)))}
placeholder={t('askCard.otherPlaceholder')}
className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
) : null}
</div>
</div>
))}
{readOnly ? (
<div className="pt-1 text-2xs text-slate-500 dark:text-slate-400">{t('askCard.closed')}</div>
) : (
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={() => void submit()}
disabled={disabled || !allAnswered}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg disabled:opacity-50"
>
{phase === 'done' ? t('askCard.submitted') : phase === 'sending' ? t('askCard.sending') : t('askCard.submit')}
</button>
{phase === 'error' ? (
<span role="alert" className="text-xs text-red-600 dark:text-red-300">{t('askCard.failed')}</span>
) : null}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
/**
* 止まったタスクから抜け出す口(issue #1199 段階 2 / #1225)。
*
* 案内がどの表示に落ちても、「解決した」と「スキップ」の両方が残ることを固定する。
* 片方だけだとタスクが出られない。#1246 でカードから道具の呼び出し行へ移したときに、
* 移し忘れると同じ穴が開くので、ここで押さえる。
*/
import '../../test/dom-setup';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import { CaptchaNotice } from './CaptchaNotice';
vi.mock('react-i18next', async importOriginal => ({
...await importOriginal<typeof import('react-i18next')>(),
useTranslation: () => ({ t: (key: string, opts?: Record<string, unknown>) => (opts?.count !== undefined ? `${key}:${String(opts.count)}` : key) }),
}));
let poolBody: unknown;
let resolveBody: unknown;
let posted: string[];
beforeEach(() => {
posted = [];
resolveBody = { ok: true, resumed: 2 };
poolBody = { available: true, captchaPending: true, pendingEngines: ['google'], novncPath: '/novnc/sess-1/vnc.html' };
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
posted.push(String(url));
return { ok: true, status: 200, json: async () => resolveBody } as Response;
}
return { ok: true, status: 200, json: async () => poolBody } as Response;
}));
});
afterEach(() => { vi.unstubAllGlobals(); });
const notice = (
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />
);
describe('「解決した」を押したとき', () => {
it('解決の口を叩き、動き出した件数を出す', async () => {
renderWithProviders(notice);
await userEvent.click(await screen.findByTestId('captcha-resolve-button'));
await waitFor(() => expect(posted).toEqual([
'/api/local/browser/sessions/captcha-pool/resolve?spaceId=space-a',
]));
expect(await screen.findByTestId('captcha-resumed')).toHaveTextContent('captcha.resumed:2');
});
it('止まっていたタスクが無ければ、その旨を出す', async () => {
resolveBody = { ok: true, resumed: 0 };
renderWithProviders(notice);
await userEvent.click(await screen.findByTestId('captcha-resolve-button'));
expect(await screen.findByTestId('captcha-resumed')).toHaveTextContent('captcha.resumedNone');
});
it('権限が無い人にはボタンが出ない(否定の経路)', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'POST') { posted.push('POST'); return { ok: false, status: 403, json: async () => ({}) } as Response; }
return { ok: false, status: 403, json: async () => ({ error: 'Forbidden' }) } as Response;
}));
renderWithProviders(notice);
await screen.findByTestId('captcha-degraded');
expect(screen.queryByTestId('captcha-resolve-button')).toBeNull();
expect(posted).toEqual([]);
});
});
describe('この呼び出しの検索先が解かれているとき', () => {
it('あとから走った検索で印が下りても、止まっているタスクを動かす口は残る', async () => {
// 印が下りる道は「解決した」だけではない。同じ検索先の検索が次に通っても下りる。
// そのとき停車したジョブは残ったままなので、口が無いと永久に止まる。
poolBody = { available: true, captchaPending: false, pendingEngines: [], novncPath: '/novnc/sess-1/vnc.html' };
renderWithProviders(notice);
await screen.findByTestId('captcha-resolved');
await userEvent.click(await screen.findByTestId('captcha-resolve-button'));
await waitFor(() => expect(posted).toEqual([
'/api/local/browser/sessions/captcha-pool/resolve?spaceId=space-a',
]));
expect(screen.getByTestId('captcha-skip-button')).toBeInTheDocument();
});
});
describe('画面が消えているとき', () => {
it('止まっているタスクを動かす口だけは残る', async () => {
poolBody = { available: false, reason: 'no_session' };
renderWithProviders(notice);
await screen.findByTestId('captcha-gone');
await userEvent.click(await screen.findByTestId('captcha-resolve-button'));
await waitFor(() => expect(posted).toEqual([
'/api/local/browser/sessions/captcha-pool/resolve?spaceId=space-a',
]));
expect(screen.getByTestId('captcha-skip-button')).toBeInTheDocument();
});
});
describe('タスクを指せない画面(履歴・共有)', () => {
it('スキップは出さない(宛先が無いので押せない)', async () => {
renderWithProviders(<CaptchaNotice spaceId="space-a" engines={['google']} />);
await screen.findByTestId('captcha-notice');
expect(screen.queryByTestId('captcha-skip-button')).toBeNull();
});
});
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
/**
* 「スキップ」——確認を解かないまま、**このタスクだけ**を動かす(issue #1199 段階 2)。
*
* ここで固定するのは、押し先が「画面(ワークスペース)」ではなく「このタスク」だという
* こと。宛先を取り違えると、同じ画面で止まっている他人のタスクまで、確認が済んでいない
* のに動き出す。
*/
import '../../test/dom-setup';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import { CaptchaNotice } from './CaptchaNotice';
vi.mock('react-i18next', async importOriginal => ({
...await importOriginal<typeof import('react-i18next')>(),
useTranslation: () => ({ t: (key: string, opts?: Record<string, unknown>) => (opts?.count !== undefined ? `${key}:${String(opts.count)}` : key) }),
}));
let poolBody: unknown;
let skipStatus: number;
let posted: string[];
let resumedCount: number;
beforeEach(() => {
posted = [];
skipStatus = 200;
resumedCount = 1;
poolBody = { available: true, captchaPending: true, pendingEngines: ['google'], novncPath: '/novnc/sess-1/vnc.html' };
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
posted.push(String(url));
return {
ok: skipStatus === 200,
status: skipStatus,
json: async () => (skipStatus === 200 ? { ok: true, resumed: resumedCount } : { error: 'x', resumed: 0 }),
} as Response;
}
return { ok: true, status: 200, json: async () => poolBody } as Response;
}));
});
afterEach(() => { vi.unstubAllGlobals(); });
const notice = (
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />
);
describe('スキップ', () => {
it('押し先はこのタスク(画面ではない)', async () => {
renderWithProviders(notice);
await userEvent.click(await screen.findByTestId('captcha-skip-button'));
await waitFor(() => expect(posted).toEqual(['/api/local/tasks/7/captcha/skip']));
// ワークスペース単位の「解決した」は叩かれない(他人のタスクを巻き込まない)
expect(posted.some((u) => u.includes('captcha-pool/resolve'))).toBe(false);
expect(await screen.findByTestId('captcha-skipped')).toBeInTheDocument();
});
it('確認を解かずに進めることが、押す前に読める', async () => {
renderWithProviders(notice);
expect(await screen.findByText('captcha.skipNote')).toBeInTheDocument();
});
it('動き出した数を出す(サブタスクごと動くので 1 とは限らない・#1225)', async () => {
resumedCount = 3;
renderWithProviders(notice);
await userEvent.click(await screen.findByTestId('captcha-skip-button'));
// 差し替えた t は count を後ろに付けて返す。サーバーの答えがそのまま文面へ渡る
expect(await screen.findByTestId('captcha-skipped')).toHaveTextContent('captcha.skipDone:3');
});
it('もう止まっていないタスクには、その旨を出す(掘り起こさない)', async () => {
skipStatus = 409;
renderWithProviders(notice);
await userEvent.click(await screen.findByTestId('captcha-skip-button'));
expect(await screen.findByTestId('captcha-skip-error')).toHaveTextContent('captcha.skipNotParked');
});
it('画面を開けない人でもスキップは出る(判定が別だから)。「解決した」は出さない', async () => {
// 例: 旧グローバル画面(管理者しか開けない)で止まった、自分のタスク。
// 画面は開けないが、検索なしで進めるかどうかは持ち主が決められる。
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (init?.method === 'POST') { posted.push(String(url)); return { ok: true, status: 200, json: async () => ({ ok: true, resumed: 1 }) } as Response; }
return { ok: false, status: 403, json: async () => ({ error: 'Forbidden' }) } as Response;
}));
renderWithProviders(notice);
await screen.findByTestId('captcha-degraded');
expect(screen.queryByTestId('captcha-resolve-button')).toBeNull();
await userEvent.click(await screen.findByTestId('captcha-skip-button'));
await waitFor(() => expect(posted).toEqual(['/api/local/tasks/7/captcha/skip']));
});
it('画面が消えていてもスキップできる(表示スタックが無い環境でも同じ)', async () => {
poolBody = { available: false, reason: 'no_session' };
renderWithProviders(notice);
await screen.findByTestId('captcha-gone');
await userEvent.click(await screen.findByTestId('captcha-skip-button'));
await waitFor(() => expect(posted).toEqual(['/api/local/tasks/7/captcha/skip']));
});
});
@@ -0,0 +1,331 @@
// @vitest-environment jsdom
/**
* 人の確認(CAPTCHA)の案内の見え方(issue #1199 段階 1 → #1246 で道具の呼び出し行へ移設)。
*
* 固定するのは 5 つ。
* - 未解決のときだけ「確認画面を開く」が出て、押すとワークスペースの識別子が上へ渡る
* - **権限が無い人にはボタンが出ない**(サーバーが 403 を返す。判定は UI に写し取らない)
* - ワークスペースに結びつかない画面は「管理者に依頼」の表示に落ちる
* - 誰かが解いたら済んだ表示になる/画面が消えてもエラーで固まらない
* - **止められた検索先ごとに出し分ける**(#1246 の本題)。Google と Brave が止められたまま
* Yahoo が通った検索でも、Google と Brave の分は案内が残る
*
* 画面のアドレスは記録に無く、この問い合わせからしか来ないことも合わせて見る。
*/
import '../../test/dom-setup';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import { CaptchaNotice, isPastCheck, pendingCaptchaEngines } from './CaptchaNotice';
vi.mock('react-i18next', async importOriginal => ({
...await importOriginal<typeof import('react-i18next')>(),
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
if (opts?.engines !== undefined) return `${key}:${String(opts.engines)}`;
if (opts?.title !== undefined) return `${key}:${String(opts.title)}`;
return key;
},
}),
}));
let respond: (url: string) => { status: number; body: unknown };
let requestedUrls: string[];
beforeEach(() => {
requestedUrls = [];
respond = () => ({ status: 200, body: { available: false, reason: 'no_session' } });
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
requestedUrls.push(String(url));
const r = respond(String(url));
return { ok: r.status >= 200 && r.status < 300, status: r.status, json: async () => r.body } as Response;
}));
});
afterEach(() => { vi.unstubAllGlobals(); });
describe('止められた検索先で出し分ける(#1246)', () => {
it('検索先ごとに未解決を見るので、通った検索先があっても案内は消えない', () => {
// Google と Brave が止められ、Yahoo が通った検索。画面には 2 つ残っている。
const { pending, names } = pendingCaptchaEngines(
{ captchaPending: true, pendingEngines: ['google', 'brave'] },
['google', 'brave'],
);
expect(pending).toBe(true);
expect(names).toEqual(['google', 'brave']);
});
it('この呼び出しの検索先が 1 つも残っていなければ、済んだ扱いにする', () => {
expect(pendingCaptchaEngines({ captchaPending: true, pendingEngines: ['bing'] }, ['google']))
.toEqual({ pending: false, names: [] });
});
it('片方だけ解かれたら、残っているほうだけを出す', () => {
expect(pendingCaptchaEngines({ captchaPending: true, pendingEngines: ['google'] }, ['google', 'brave']))
.toEqual({ pending: true, names: ['google'] });
});
it('サーバーが検索先の一覧を返さない(古い版)なら、画面ぜんぶの旗に従う', () => {
expect(pendingCaptchaEngines({ captchaPending: true }, ['google']))
.toEqual({ pending: true, names: ['google'] });
expect(pendingCaptchaEngines({ captchaPending: false }, ['google']))
.toEqual({ pending: false, names: ['google'] });
});
it('記録に検索先が無い(旧い形のコメント)なら、画面に残っている分をそのまま出す', () => {
expect(pendingCaptchaEngines({ captchaPending: true, pendingEngines: ['google'] }, undefined))
.toEqual({ pending: true, names: ['google'] });
expect(pendingCaptchaEngines({ captchaPending: false, pendingEngines: [] }, undefined))
.toEqual({ pending: false, names: [] });
});
});
describe('未解決のとき', () => {
beforeEach(() => {
respond = () => ({
status: 200,
body: { available: true, captchaPending: true, pendingEngines: ['google', 'brave'], novncPath: '/novnc/sess-1/vnc.html' },
});
});
it('ボタンが出て、押すとワークスペースの識別子が上へ渡る', async () => {
const onOpen = vi.fn();
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={onOpen} />,
);
await userEvent.click(await screen.findByTestId('captcha-open-button'));
expect(onOpen).toHaveBeenCalledWith('space-a');
});
it('止められた検索先の名前が読める', async () => {
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google', 'brave']} onOpenCaptchaScreen={vi.fn()} />,
);
expect(await screen.findByTestId('captcha-blocked-engines'))
.toHaveTextContent('captcha.blockedEngines:google, brave');
});
it('通った検索先は名前に出ない', async () => {
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google', 'yahoo']} onOpenCaptchaScreen={vi.fn()} />,
);
const label = await screen.findByTestId('captcha-blocked-engines');
expect(label).toHaveTextContent('captcha.blockedEngines:google');
expect(label.textContent).not.toContain('yahoo');
});
it('名前の無い印は、そのまま「(unknown)」と出さずに言葉にする', async () => {
respond = () => ({ status: 200, body: { available: true, captchaPending: true, pendingEngines: ['(unknown)'] } });
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['(unknown)']} onOpenCaptchaScreen={vi.fn()} />,
);
const label = await screen.findByTestId('captcha-blocked-engines');
expect(label).toHaveTextContent('captcha.blockedEngines:captcha.unknownEngine');
});
it('画面のアドレスは権限確認つきの問い合わせから取る', async () => {
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />,
);
await screen.findByTestId('captcha-open-button');
expect(requestedUrls[0]).toBe('/api/local/browser/sessions/captcha-pool?spaceId=space-a');
});
it('開き先を渡されない画面ではボタンを出さない', async () => {
renderWithProviders(<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} />);
await screen.findByTestId('captcha-no-entry');
expect(screen.queryByTestId('captcha-open-button')).toBeNull();
});
});
describe('権限が無いとき(否定の経路)', () => {
it('403 ならボタンを出さない', async () => {
respond = () => ({ status: 403, body: { error: 'Forbidden' } });
const onOpen = vi.fn();
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={onOpen} />,
);
expect(await screen.findByTestId('captcha-degraded')).toHaveTextContent('captcha.noPermission');
expect(screen.queryByTestId('captcha-open-button')).toBeNull();
expect(onOpen).not.toHaveBeenCalled();
});
it('ワークスペースに結びつかない画面は「管理者に依頼」になる', async () => {
respond = () => ({ status: 403, body: { error: 'Forbidden' } });
renderWithProviders(
<CaptchaNotice spaceId={null} taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />,
);
expect(await screen.findByTestId('captcha-degraded')).toHaveTextContent('captcha.askAdmin');
expect(screen.queryByTestId('captcha-open-button')).toBeNull();
// spaceId が無いので問い合わせにも付かない
expect(requestedUrls[0]).toBe('/api/local/browser/sessions/captcha-pool');
});
});
describe('解けたあと・画面が消えたあと', () => {
it('この呼び出しの検索先が解かれたら済んだ表示になり、ボタンは消える', async () => {
respond = () => ({ status: 200, body: { available: true, captchaPending: true, pendingEngines: ['bing'] } });
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />,
);
await screen.findByTestId('captcha-resolved');
expect(screen.queryByTestId('captcha-open-button')).toBeNull();
});
it('画面がリセットされてもエラーで固まらない', async () => {
respond = () => ({ status: 200, body: { available: false, reason: 'no_session' } });
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />,
);
await waitFor(() => expect(screen.getByTestId('captcha-gone')).toBeInTheDocument());
expect(screen.queryByTestId('captcha-open-button')).toBeNull();
});
});
/**
* カードは止められた検索先の名前を持たない(本文に書くと書いた時点で古くなるため)。
* 名前で見分けられないぶん、未解決期間の番号(seq)で自分の確認を見分ける(#1246)。
* 見比べないと、あとから起きた無関係な確認のたびに過去のカードがぜんぶ色を取り戻す。
*/
describe('カードは自分の確認を番号で見分ける(#1246)', () => {
it('サーバーの番号が進んでいれば、そのカードの確認は終わっている', () => {
expect(isPastCheck(2, 5)).toBe(true);
expect(isPastCheck(5, 5)).toBe(false);
// 呼び出し行(番号を持たない)とサーバーが番号を返さない場合は、この判定に入らない
expect(isPastCheck(undefined, 5)).toBe(false);
expect(isPastCheck(2, null)).toBe(false);
});
it('番号が古いカードは静かな表示に落ちる(「解決した」は出さない)', async () => {
respond = () => ({
status: 200,
body: { available: true, captchaPending: true, pendingEngines: ['google'], seq: 5 },
});
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} cardSeq={2} onOpenCaptchaScreen={vi.fn()} />,
);
expect(await screen.findByTestId('captcha-past-check')).toHaveTextContent('captcha.pastCheck');
// いま出ている確認は別物。ここで押されると、解いていない確認まで解決済みにしてしまう
expect(screen.queryByTestId('captcha-resolve-button')).toBeNull();
expect(screen.queryByTestId('captcha-open-button')).toBeNull();
// 止まったままなら抜けられる口は残す
expect(screen.getByTestId('captcha-skip-button')).toBeInTheDocument();
});
/**
* 塞がれている検索先が 1 つも無いなら、いま応じるべき確認そのものが存在しない。
* このとき「いま求められているのは、あとから出た別の確認です」と出すのは嘘になる。
* 番号の新旧を見るより先に「済んだ」の表示へ落とす(枝の順番でそうしている)。
* 押しても解いてしまう相手が居ないので、「解決した」も残す。
* サブタスクの見出しも残す——3 つ並んで走れば、済んだカードも 3 枚並ぶ。
*/
it('番号が古くても、塞がれている検索先が無ければ「済んだ」表示になる', async () => {
respond = () => ({
status: 200,
body: { available: true, captchaPending: false, pendingEngines: [], seq: 5 },
});
renderWithProviders(
<CaptchaNotice
spaceId="space-a"
taskId={7}
cardSeq={2}
subtaskTitle="価格を調べる"
onOpenCaptchaScreen={vi.fn()}
/>,
);
expect(await screen.findByTestId('captcha-resolved')).toBeInTheDocument();
expect(screen.queryByTestId('captcha-past-check')).toBeNull();
// どのサブタスクの話だったのかは、済んだあとも分かる必要がある
expect(screen.getByTestId('captcha-subtask')).toHaveTextContent('captcha.subtaskLabel:価格を調べる');
expect(screen.getByTestId('captcha-resolve-button')).toBeInTheDocument();
expect(screen.getByTestId('captcha-skip-button')).toBeInTheDocument();
});
it('番号が同じで塞がれている検索先も無ければ、これまでどおり「済んだ」表示', async () => {
respond = () => ({
status: 200,
body: { available: true, captchaPending: false, pendingEngines: [], seq: 5 },
});
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} cardSeq={5} onOpenCaptchaScreen={vi.fn()} />,
);
expect(await screen.findByTestId('captcha-resolved')).toBeInTheDocument();
expect(screen.queryByTestId('captcha-past-check')).toBeNull();
});
it('番号が同じカードは、いつもどおり案内を出す', async () => {
respond = () => ({
status: 200,
body: { available: true, captchaPending: true, pendingEngines: ['google'], seq: 5 },
});
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} cardSeq={5} onOpenCaptchaScreen={vi.fn()} />,
);
expect(await screen.findByTestId('captcha-open-button')).toBeInTheDocument();
expect(screen.queryByTestId('captcha-past-check')).toBeNull();
});
it('呼び出し行は番号を持たないので、番号では静かにならない', async () => {
respond = () => ({
status: 200,
body: { available: true, captchaPending: true, pendingEngines: ['google'], seq: 5 },
});
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />,
);
expect(await screen.findByTestId('captcha-open-button')).toBeInTheDocument();
expect(screen.queryByTestId('captcha-past-check')).toBeNull();
});
});
/**
* サブタスクが 3 つ並んで走って 3 つとも止められたら、カードは 3 枚出る(#1246)。
* どれの話か書いていないと、同じ見た目のカードが並ぶだけになる。
*/
describe('どのサブタスクが止まっているか', () => {
beforeEach(() => {
respond = () => ({
status: 200,
body: { available: true, captchaPending: true, pendingEngines: ['google'], seq: 1 },
});
});
it('サブタスクの見出しが案内に出る', async () => {
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} cardSeq={1} subtaskTitle="価格を調べる" onOpenCaptchaScreen={vi.fn()} />,
);
expect(await screen.findByTestId('captcha-subtask'))
.toHaveTextContent('captcha.subtaskLabel:価格を調べる');
});
it('見出しが取れなかったときも、サブタスクの話だと分かる', async () => {
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} cardSeq={1} subtaskTitle="" onOpenCaptchaScreen={vi.fn()} />,
);
expect(await screen.findByTestId('captcha-subtask')).toHaveTextContent('captcha.subtaskUnnamed');
});
it('本体のタスクの案内には、サブタスクの行を出さない', async () => {
renderWithProviders(
<CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />,
);
await screen.findByTestId('captcha-open-button');
expect(screen.queryByTestId('captcha-subtask')).toBeNull();
});
});
+358
View File
@@ -0,0 +1,358 @@
import { useTranslation } from 'react-i18next';
import { useCaptchaPool, useResolveCaptcha, useSkipCaptcha, type CaptchaPoolState } from '../../hooks/useCaptchaPool';
/**
* 人の確認(CAPTCHA)の案内。**道具の呼び出し行の中**に出る(issue #1246)。
*
* もとは会話に浮かぶ独立したカードだった。カードは「そのワークスペースで確認が未解決か」
* という真偽値ひとつを見ていたので、Google と Brave が止められたまま Yahoo が通った検索では
* 印が同じ検索の中で下ろされ、案内が一度も出なかった。いまはサーバーが**検索先ごとに**印を
* 持ち、止められた検索先の名前が呼び出しの記録に載る。ここはその名前と、いま画面に残って
* いる分(`pendingEngines`)を突き合わせて出し分ける。
*
* 画面のアドレスは記録に入っていない。`GET /captcha-pool?spaceId=` を引き、サーバーが権限を
* 認めたときだけ「開く」を出す(判定を UI 側に写し取らないこと)。
*/
/** サーバーが検索先の名前を伴わずに付けた印(`browser-session.ts` の `UNNAMED_CAPTCHA_ENGINE`) */
export const UNNAMED_CAPTCHA_ENGINE = '(unknown)';
/**
* この呼び出し行が、いま応じるべき確認を抱えているか。
*
* @param data `/captcha-pool` の答え
* @param engines この呼び出しで止められた検索先。旧いカード由来のコメントには無い(undefined)
*/
export function pendingCaptchaEngines(
data: Pick<CaptchaPoolState, 'captchaPending' | 'pendingEngines'> | undefined,
engines: string[] | undefined,
): { pending: boolean; names: string[] } {
const server = data?.pendingEngines;
if (!Array.isArray(server)) {
// サーバーが古くて検索先ごとの一覧を返さない。画面ぜんぶの旗に従う(#1246 以前の挙動)。
return { pending: data?.captchaPending === true, names: engines ?? [] };
}
if (!engines) {
// 記録に検索先が無い(#1246 以前に出た確認カードのコメント)。画面に残っている分を出す。
return { pending: server.length > 0, names: server };
}
const names = engines.filter((e) => server.includes(e));
return { pending: names.length > 0, names };
}
/**
* このカードが指す確認が、もう終わった過去のものか(issue #1246)。
*
* 呼び出し行は「止められた検索先の名前」で自分の確認を見分けられるが、カードは名前を
* 持たない(`src/worker/captcha-card.ts` の注記——名前は書いた時点で古くなるため)。
* 代わりに未解決期間の番号(`seq`)を焼き付けてあるので、画面がいま抱えている番号と
* 見比べる。サーバーの番号のほうが大きい=このカードの確認は終わり、いま出ているのは
* **あとから起きた別の確認**。見比べないと、無関係な確認のたびに過去のカードが
* ぜんぶ「未解決」の色に戻る。
*
* @param cardSeq カードに焼き付けた番号。呼び出し行から来たときは undefined
* @param serverSeq `/captcha-pool` がいま返している番号
*/
export function isPastCheck(cardSeq: number | undefined, serverSeq: number | null | undefined): boolean {
if (typeof cardSeq !== 'number' || typeof serverSeq !== 'number') return false;
return serverSeq > cardSeq;
}
function Shell({ children, tone }: { children: React.ReactNode; tone: 'pending' | 'quiet' }) {
const cls = tone === 'pending'
? 'border-amber-300 bg-amber-50 dark:border-amber-700/60 dark:bg-amber-900/20'
: 'border-hairline bg-canvas';
return (
<div data-testid="captcha-notice" className={`mt-1 rounded-md border px-2.5 py-2 text-xs ${cls}`}>
{children}
</div>
);
}
export function CaptchaNotice({
spaceId,
taskId,
engines,
cardSeq,
subtaskTitle,
onOpenCaptchaScreen,
}: {
spaceId: string | null;
/**
* スキップの宛先。スキップが効くのは**このタスクとその配下のサブタスク**だけなので、
* 画面(ワークスペース)ではなくタスクを指す(#1225)。渡らない画面では出さない。
*/
taskId?: number;
/** この呼び出しで確認に止められた検索先。カード由来のときは無い */
engines?: string[];
/** カードに焼き付けた未解決期間の番号。カード由来のときだけ渡す(`isPastCheck`) */
cardSeq?: number;
/** どのサブタスクが止まっているか。サブタスク由来のカードだけが持つ(#1246) */
subtaskTitle?: string;
/** 押せる画面でだけ渡す。渡らなければ「開く」を出さない(履歴・共有ページなど) */
onOpenCaptchaScreen?: (spaceId: string | null) => void;
}) {
const { t } = useTranslation('chat');
const { data, isLoading } = useCaptchaPool(spaceId);
const resolve = useResolveCaptcha(spaceId);
// フックは条件付きで呼べないので、宛先が無いときも呼んでボタン側を出さない。
const skip = useSkipCaptcha(taskId ?? 0);
const { pending, names } = pendingCaptchaEngines(data, engines);
const engineLabel = names
.map((e) => (e === UNNAMED_CAPTCHA_ENGINE ? t('captcha.unknownEngine') : e))
.join(', ');
const resolveButton = (
<div className="mt-1.5">
<button
type="button"
data-testid="captcha-resolve-button"
disabled={resolve.isPending}
onClick={() => resolve.mutate()}
className="inline-flex items-center gap-1.5 rounded-md border border-emerald-500 bg-white px-2.5 py-1 text-[11px] font-medium text-emerald-800 hover:bg-emerald-50 disabled:opacity-60 dark:border-emerald-600 dark:bg-slate-900 dark:text-emerald-200 dark:hover:bg-slate-800"
>
{resolve.isPending ? t('captcha.resolving') : t('captcha.resolveButton')}
</button>
{resolve.isSuccess && (
<p className="mt-1 text-2xs text-emerald-700 dark:text-emerald-300" data-testid="captcha-resumed">
{resolve.data.resumed > 0
? t('captcha.resumed', { count: resolve.data.resumed })
: t('captcha.resumedNone')}
</p>
)}
{resolve.isError && (
<p className="mt-1 text-2xs text-red-700 dark:text-red-300" data-testid="captcha-resolve-error">
{t('captcha.resolveFailed')}
</p>
)}
</div>
);
/**
* 「スキップ」——確認を解かないまま、**このタスクと、その配下で止まっている
* サブタスク**を動かす(#1225)。
*
* 「解決した」と並べて出すが、意味も範囲も別物。あちらは確認が済んだことを画面に
* 伝えて、同じ画面で止まっている**他人のタスクも含めた全タスク**を動かす。こちらは
* 確認を解かないと決めて、このタスクの系統にだけ「検索は使えない」と伝えて動かす。
* 押せるかどうかはサーバーが見る(このタスクを操作できる人か)。
*/
const skipButton = taskId === undefined ? null : (
<div className="mt-1.5">
<button
type="button"
data-testid="captcha-skip-button"
disabled={skip.isPending}
onClick={() => skip.mutate()}
className="inline-flex items-center gap-1.5 rounded-md border border-hairline bg-canvas px-2.5 py-1 text-[11px] font-medium text-slate-700 hover:bg-surface disabled:opacity-60 dark:text-slate-200"
>
{skip.isPending ? t('captcha.skipping') : t('captcha.skipButton')}
</button>
<p className="mt-1 text-2xs text-slate-500 dark:text-slate-400">{t('captcha.skipNote')}</p>
{skip.isSuccess && (
<p className="mt-1 text-2xs text-slate-600 dark:text-slate-300" data-testid="captcha-skipped">
{t('captcha.skipDone', { count: skip.data?.resumed ?? 0 })}
</p>
)}
{skip.isError && (
<p className="mt-1 text-2xs text-red-700 dark:text-red-300" data-testid="captcha-skip-error">
{skip.error?.kind === 'not_parked' ? t('captcha.skipNotParked') : t('captcha.skipFailed')}
</p>
)}
</div>
);
/** 止まったタスクから抜け出す口。どの表示に落ちても両方残す(片方だけだと出られない) */
const exitButtons = (
<>
{resolveButton}
{skipButton}
</>
);
const title = (
<div className="flex items-center gap-1.5 font-medium text-amber-900 dark:text-amber-200">
<svg className="h-3.5 w-3.5 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 9v4M12 17h.01" />
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</svg>
<span>{t('captcha.title')}</span>
</div>
);
/**
* どのサブタスクが止まっているか。並列に走ったサブタスクが 3 つとも止められたら
* カードは 3 枚出るので、これが無いとどれの話か分からない(#1246)。
*/
const subtaskLine = subtaskTitle === undefined ? null : (
<p className="mt-1 text-2xs text-slate-600 dark:text-slate-300" data-testid="captcha-subtask">
{subtaskTitle
? t('captcha.subtaskLabel', { title: subtaskTitle })
: t('captcha.subtaskUnnamed')}
</p>
);
if (isLoading) {
return (
<Shell tone="pending">
{title}
<p className="mt-1 text-2xs text-slate-600 dark:text-slate-300">{t('captcha.loading')}</p>
</Shell>
);
}
// 権限が無い(このワークスペースの参加者ではない)。公開範囲の広いタスクでは起こりうる。
if (data?.forbidden) {
return (
<Shell tone="quiet">
{title}
{subtaskLine}
<p className="mt-1 text-2xs text-slate-600 dark:text-slate-300" data-testid="captcha-degraded">
{spaceId === null ? t('captcha.askAdmin') : t('captcha.noPermission')}
</p>
{/*
画面は開けないが、タスクの持ち主ならスキップはできる(判定が別だから)。
「解決した」は出さない——確認画面を見られない人が「確認は済んだ」と宣言して
他人のタスクまで動かす操作なので、サーバーも 403 で断る。
*/}
{skipButton}
</Shell>
);
}
// 人が確認を解く画面を開けない。理由はサーバーが返している(`browser-api.ts` の
// `unavailableReason` と、配布物が無いときの `novnc_not_installed`)。
//
// かつては 4 つとも「画面はもうありません。次に確認を求められたときに作り直されます」
// の 1 文に潰していた。それが正しいのは `no_session` だけで、残る 3 つでは**事実として
// 誤り**——表示方式が headless のあいだも、表示の仕組みや noVNC の配布物がホストに無い
// あいだも、次の確認で画面が作られることはない。理由ごとに、何が起きているか・誰が何を
// すれば解けるようになるかまで書く(issue #1341)。
//
// どの理由でも「解決した」「スキップ」は必ず残す。この 2 つが、確認で停まったタスクを
// 動かす唯一の口だから(片方でも落とすと、その状態のタスクはカードから出られない)。
if (!data?.available) {
const reason = data?.reason;
// 理由が付かない答えもある(取得に失敗した画面の既定値)。その場合は従来の文面へ。
const explained = reason === 'headless_mode'
|| reason === 'display_unavailable'
|| reason === 'novnc_not_installed';
return (
<Shell tone="quiet">
{title}
{subtaskLine}
{explained ? (
<div data-testid="captcha-unavailable" data-reason={reason}>
<p className="mt-1 text-2xs font-medium text-slate-700 dark:text-slate-200">
{t(`captcha.unavailable.${reason}.title`)}
</p>
<p className="mt-1 text-2xs leading-relaxed text-slate-600 dark:text-slate-300">
{t(`captcha.unavailable.${reason}.body`)}
</p>
</div>
) : (
<p className="mt-1 text-2xs text-slate-600 dark:text-slate-300" data-testid="captcha-gone">
{t('captcha.screenGone')}
</p>
)}
{/*
「止まったタスクは動かせる」の一文も理由で分ける。`goneResume` は
「画面はありませんが」で始まるが、`novnc_not_installed` は**画面がある**枝
(`browser-api.ts` は pool が見つかったあとでしか返さない)で、直上の見出しも
「確認は残っていますが、画面を映せません」と言う。同じカードの中で食い違う。
画面が本当に無い `no_session` と、理由が取れない既定(取得に失敗して `data` が
無い枝。画面の有無は分からないので、これまでどおりの文面に落とす)に
`goneResume` を残し、出し分けた 3 つには、どの理由でも成り立つ
`unavailableResume` を当てる。
*/}
<p className="mt-1 text-2xs text-slate-500 dark:text-slate-400" data-testid="captcha-resume-line">
{t(explained ? 'captcha.unavailableResume' : 'captcha.goneResume')}
</p>
{exitButtons}
</Shell>
);
}
// いま塞がれている検索先が 1 つも無い。カードの番号が古いかどうかより先にここで拾う——
// 塞がれた検索先がゼロなら、応じるべき確認そのものが存在しないので、「いま求められて
// いるのは別の確認です」と言えない。番号が古いままでも表示は「済んだ」で正しい。
// (`markCaptchaPending(false, ...)` は検索先の一覧を空にするだけで番号を戻さないので、
// 「2 つ目の確認が起きて、そのあと解かれた」だけでこの形になる)
if (!pending) {
return (
<Shell tone="quiet">
<div className="flex items-center gap-1.5 font-medium text-emerald-700 dark:text-emerald-300">
<svg className="h-3.5 w-3.5 flex-shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8.5l3.5 3.5L13 5" />
</svg>
<span data-testid="captcha-resolved">{t('captcha.resolved')}</span>
</div>
{subtaskLine}
<p className="mt-1 text-2xs text-slate-600 dark:text-slate-300">{t('captcha.resolvedBody')}</p>
{/*
旗が下りる道は 2 つある。「解決した」を押した場合と、あとから走った検索が
たまたま通った場合。後者では停車したタスクが残ったままなので、動かす口を残す。
*/}
<p className="mt-1 text-2xs text-slate-500 dark:text-slate-400">{t('captcha.resolvedResume')}</p>
{exitButtons}
</Shell>
);
}
// このカードの確認は終わっていて、いま画面に出ているのは別の確認(#1246)。
// 「解決した」を押すと**いま出ている確認**が解かれてしまうので、ここでは出さない。
// スキップはこのタスクを動かすだけなので残す(止まったままなら押して抜けられる)。
//
// 塞がれた検索先がゼロの場合をひとつ上の枝で先に返しているので、ここへ来るのは
// `pending === true` だけ——解いてしまう相手が必ず居る。「塞がれていないときは
// どうするか」をこの枝で場合分けする必要はない(枝の順番がその役目を負う)。
if (isPastCheck(cardSeq, data.seq)) {
return (
<Shell tone="quiet">
{title}
{subtaskLine}
<p className="mt-1 text-2xs text-slate-600 dark:text-slate-300" data-testid="captcha-past-check">
{t('captcha.pastCheck')}
</p>
{skipButton}
</Shell>
);
}
return (
<Shell tone="pending">
{title}
{subtaskLine}
{engineLabel && (
<p className="mt-1 text-2xs font-medium text-amber-900 dark:text-amber-200" data-testid="captcha-blocked-engines">
{t('captcha.blockedEngines', { engines: engineLabel })}
</p>
)}
<p className="mt-1 text-2xs leading-relaxed text-amber-800/90 dark:text-amber-200/80">
{t('captcha.body')}
</p>
<p className="mt-1 text-2xs leading-relaxed text-amber-700/70 dark:text-amber-300/60">
{t('captcha.sharedNote')}
</p>
<p className="mt-1 text-2xs leading-relaxed text-amber-800/90 dark:text-amber-200/80" data-testid="captcha-after-note">
{t('captcha.afterNote')}
</p>
{onOpenCaptchaScreen ? (
<button
type="button"
data-testid="captcha-open-button"
onClick={() => onOpenCaptchaScreen(spaceId)}
className="mt-1.5 inline-flex items-center gap-1.5 rounded-md border border-amber-400 bg-white px-2.5 py-1 text-[11px] font-medium text-amber-900 hover:bg-amber-100 dark:border-amber-600 dark:bg-slate-900 dark:text-amber-200 dark:hover:bg-slate-800"
>
{t('captcha.open')}
</button>
) : (
<p className="mt-1.5 text-2xs text-slate-500" data-testid="captcha-no-entry">
{t('captcha.openElsewhere')}
</p>
)}
{exitButtons}
</Shell>
);
}
@@ -0,0 +1,170 @@
// @vitest-environment jsdom
/**
* 人が確認を解く画面を開けないときの案内(issue #1341)。
*
* 以前は理由 4 つをまとめて「画面はもうありません。次に確認を求められたときに作り直され
* ます」の 1 文にしていた。それが正しいのは `no_session` だけで、残る 3 つでは事実として
* 誤り——表示方式が headless のあいだも、表示の仕組みや noVNC の配布物が無いあいだも、
* 次の確認で画面が作られることはない。理由ごとに別の文面が出ることを固定する。
*
* あわせて、**どの理由でも「解決した」と「スキップ」の両方が残る**ことを見る。この 2 つが
* 確認で停まったタスクを動かす唯一の口で、文面だけを見る検査ではボタンが消えても気づけない。
*/
import '../../test/dom-setup';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
import { CaptchaNotice } from './CaptchaNotice';
import jaChat from '../../i18n/locales/ja/chat.json';
import enChat from '../../i18n/locales/en/chat.json';
import jaAdmin from '../../i18n/locales/ja/admin.json';
import enAdmin from '../../i18n/locales/en/admin.json';
import jaDetail from '../../i18n/locales/ja/detail.json';
import enDetail from '../../i18n/locales/en/detail.json';
vi.mock('react-i18next', async importOriginal => ({
...await importOriginal<typeof import('react-i18next')>(),
useTranslation: () => ({ t: (key: string, opts?: Record<string, unknown>) => (opts?.count !== undefined ? `${key}:${String(opts.count)}` : key) }),
}));
let poolBody: unknown;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => poolBody } as Response)));
});
afterEach(() => { vi.unstubAllGlobals(); });
const notice = <CaptchaNotice spaceId="space-a" taskId={7} engines={['google']} onOpenCaptchaScreen={vi.fn()} />;
const explained = [
'headless_mode',
'display_unavailable',
'novnc_not_installed',
] as const;
describe('画面を開けない理由ごとの文面', () => {
for (const reason of explained) {
it(`${reason}: その理由だけの文面が出る`, async () => {
poolBody = { available: false, reason };
renderWithProviders(notice);
const box = await screen.findByTestId('captcha-unavailable');
expect(box).toHaveAttribute('data-reason', reason);
expect(box).toHaveTextContent(`captcha.unavailable.${reason}.title`);
expect(box).toHaveTextContent(`captcha.unavailable.${reason}.body`);
// 「画面はもうありません」に潰さない(この文面が正しいのは no_session だけ)
expect(screen.queryByTestId('captcha-gone')).toBeNull();
});
it(`${reason}: 止まったタスクを動かす口が両方残る`, async () => {
poolBody = { available: false, reason };
renderWithProviders(notice);
await screen.findByTestId('captcha-unavailable');
expect(screen.getByTestId('captcha-resolve-button')).toBeInTheDocument();
expect(screen.getByTestId('captcha-skip-button')).toBeInTheDocument();
});
it(`${reason}: 「動かせます」の一文が「画面はありません」と言わない`, async () => {
poolBody = { available: false, reason };
renderWithProviders(notice);
// `goneResume` は「画面はありませんが」で始まる。novnc_not_installed は画面がある
// 枝なので、同じカードの見出し(「確認は残っていますが、画面を映せません」)と
// 食い違う。3 つとも `unavailableResume` に寄せる。
const line = await screen.findByTestId('captcha-resume-line');
expect(line).toHaveTextContent('captcha.unavailableResume');
expect(line).not.toHaveTextContent('captcha.goneResume');
});
}
it('no_session: 従来どおり「画面はもうありません」を出し、口も両方残る', async () => {
poolBody = { available: false, reason: 'no_session' };
renderWithProviders(notice);
await screen.findByTestId('captcha-gone');
expect(screen.queryByTestId('captcha-unavailable')).toBeNull();
expect(screen.getByTestId('captcha-resume-line')).toHaveTextContent('captcha.goneResume');
expect(screen.getByTestId('captcha-resolve-button')).toBeInTheDocument();
expect(screen.getByTestId('captcha-skip-button')).toBeInTheDocument();
});
it('理由が付かない答え(取得に失敗した画面の既定値)も従来の文面に落とす', async () => {
poolBody = { available: false };
renderWithProviders(notice);
await screen.findByTestId('captcha-gone');
expect(screen.queryByTestId('captcha-unavailable')).toBeNull();
expect(screen.getByTestId('captcha-resume-line')).toHaveTextContent('captcha.goneResume');
expect(screen.getByTestId('captcha-resolve-button')).toBeInTheDocument();
expect(screen.getByTestId('captcha-skip-button')).toBeInTheDocument();
});
});
describe('文面そのもの(日本語・英語)', () => {
it.each([['ja', jaChat], ['en', enChat]] as const)('%s: 理由ごとに別の文面が入っている', (_lang, res) => {
const box = (res as any).captcha.unavailable;
const bodies = explained.map((r) => {
expect(typeof box[r].title).toBe('string');
expect(box[r].title.length).toBeGreaterThan(0);
expect(typeof box[r].body).toBe('string');
expect(box[r].body.length).toBeGreaterThan(0);
return box[r].body;
});
expect(new Set(bodies).size).toBe(explained.length);
});
it.each([['ja', jaChat], ['en', enChat]] as const)('%s: 次にやることが書いてある', (_lang, res) => {
const box = (res as any).captcha.unavailable;
// headless は設定の場所、残り 2 つは管理者に頼む導入手段を必ず指す
expect(box.headless_mode.body).toContain('novnc');
expect(box.display_unavailable.body).toContain('websockify');
expect(box.novnc_not_installed.body).toContain('setup-novnc.sh');
});
it.each([['ja', jaAdmin], ['en', enAdmin]] as const)('%s: 管理画面にも配布物なしの文面がある', (_lang, res) => {
const s = (res as any).captcha.novncNotInstalled;
expect(typeof s).toBe('string');
expect(s).toContain('setup-novnc.sh');
expect(s).not.toBe((res as any).captcha.noSession);
});
/**
* 表示スタックの判定(`SessionManager.isAvailable`)は Xvfb・x11vnc・websockify の
* **どれか 1 つでも欠ければ** false を返す。「3 つとも入っていません」と書くと、
* 1 つだけ欠けている環境で嘘になる(実際に x11vnc と websockify だけが欠けた記録が
* 出ている)。どれが欠けたかはサーバーが漏らさない決まりなので、「いずれか」で書く。
*/
it.each([
['ja', jaChat, 'のいずれかが'],
['en', enChat, 'One or more of'],
] as const)('%s: 表示スタックの文面は「いずれか」で書く', (_lang, res, phrase) => {
expect((res as any).captcha.unavailable.display_unavailable.body).toContain(phrase);
});
/**
* `unavailableReason` は表示方式を最優先で見るので、表示スタックがそもそも無い
* ホストでも、設定が headless なら `headless_mode` を返す。そこで novnc に変えても
* `display_unavailable` に落ちるだけで解けるようにはならない。「novnc にすれば
* 解ける」と言い切らず、同じ条件節を 3 か所(チャット・管理画面・タスク詳細)に
* 揃えて置く。1 か所だけ直すとここで落ちる。
*/
it.each([
['ja', '入っていない場合は、切り替えても画面は出ません', jaChat, jaAdmin, jaDetail],
['en', 'switching alone will not produce a screen', enChat, enAdmin, enDetail],
] as const)('%s: headless の案内は 3 か所とも「切り替えただけでは出ない」を言う', (_lang, clause, chat, admin, detail) => {
const places = [
(chat as any).captcha.unavailable.headless_mode.body,
(admin as any).captcha.headlessMode,
(detail as any).browser.headless.body,
];
for (const text of places) expect(text).toContain(clause);
});
it.each([['ja', jaChat], ['en', enChat]] as const)('%s: 画面が開けない枝の「動かせます」は「画面はありません」と言わない', (_lang, res) => {
const c = (res as any).captcha;
expect(typeof c.unavailableResume).toBe('string');
expect(c.unavailableResume.length).toBeGreaterThan(0);
expect(c.unavailableResume).not.toBe(c.goneResume);
});
});
@@ -0,0 +1,173 @@
// @vitest-environment jsdom
//
// 入力欄の添付一覧(#1553)。
//
// 元の申告は「名前が長いとスクロールバーが出て、消すボタンが見つからない」。
// 守りたいのは見た目の好みではなく、操作できるかどうかの話:
// 1. 0 件なら何も描かない(余白を増やさない)
// 2. 1 件 1 行で縦に積む(行そのものが横並びで、名前が縮み、ボタンが右端)
// 3. 長い名前は 1 行に省略し、全文は title で読める
// 4. 消すボタンは常に見えていて、押すとその 1 件だけが消える
// 5. 操作の名前にファイル名が入り、辞書から引かれている
//
// **多言語化を実物の辞書で初期化してから描く。**初期化しないと t() は鍵の
// 文字列をそのまま返し、ファイル名が 1 文字も入らない。その状態で名前を見る
// 検査を書くと、画面直書きに差し替えても緑のままになる(前の版がそれで
// 空振りしていた)。作法は WorkerStatusWindow.test.tsx 等と揃える。
import '../../test/dom-setup';
import { useState } from 'react';
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import jaChat from '../../i18n/locales/ja/chat.json';
import enChat from '../../i18n/locales/en/chat.json';
import { ChatAttachmentList, type ChatAttachment } from './ChatAttachmentList';
beforeAll(async () => {
await i18n.use(initReactI18next).init({
lng: 'ja',
resources: { ja: { chat: jaChat }, en: { chat: enChat } },
ns: ['chat'],
defaultNS: 'chat',
interpolation: { escapeValue: false },
});
});
afterEach(async () => {
// 言語を切り替える検査があるので、毎回 ja に戻す。
if (i18n.language !== 'ja') await i18n.changeLanguage('ja');
});
/** 辞書の文言にファイル名を差し込んだ、画面に出るはずの文字列。 */
function removeLabel(catalog: { attachment: { remove: string } }, name: string): string {
return catalog.attachment.remove.replace('{{name}}', name);
}
function attachment(name: string): ChatAttachment {
return { name, contentBase64: 'AAAA' };
}
/** 本番と同じく「消したら一覧から減る」まで動かす受け皿。 */
function Harness({ initial }: { initial: ChatAttachment[] }) {
const [attachments, setAttachments] = useState(initial);
return (
<ChatAttachmentList
attachments={attachments}
onRemove={name => setAttachments(prev => prev.filter(a => a.name !== name))}
/>
);
}
/** 名前でボタンを引く。役割と名前だけで取れることがそのまま検査になる。 */
function removeButton(name: string): HTMLElement {
return screen.getByRole('button', { name: removeLabel(jaChat, name) });
}
describe('ChatAttachmentList (#1553)', () => {
it('添付が 0 件なら何も描かない', () => {
const { container } = render(<ChatAttachmentList attachments={[]} onRemove={() => {}} />);
expect(container.innerHTML).toBe('');
expect(screen.queryByRole('list')).toBeNull();
});
it('1 件 1 行で縦に積む(折り返さない)', () => {
render(<ChatAttachmentList attachments={[attachment('a.txt'), attachment('b.txt'), attachment('c.txt')]} onRemove={() => {}} />);
const rows = screen.getAllByRole('listitem');
expect(rows).toHaveLength(3);
const list = screen.getByRole('list', { name: jaChat.attachment.listLabel });
// 縦積み。横並びに戻したら赤くする——1 行に複数件を押し込む形が元の不具合。
expect(list.classList.contains('flex')).toBe(true);
expect(list.classList.contains('flex-col')).toBe(true);
expect(list.classList.contains('flex-wrap')).toBe(false);
});
it('行そのものは横並びで、名前が縮み、ボタンは縮まない', () => {
render(<ChatAttachmentList attachments={[attachment('a.txt')]} onRemove={() => {}} />);
const row = screen.getByRole('listitem');
// 行が横並びでないと名前とボタンが縦に割れ、ボタンが右端から離れる(実測で
// 429px 内側へずれ、横スクロールバーまで復活した)。
expect(row.classList.contains('flex')).toBe(true);
expect(row.classList.contains('items-center')).toBe(true);
const label = screen.getByTitle('a.txt');
// 名前が縮めないと、長い名前の分だけ行が広がってボタンを右端から押し出す。
expect(label.classList.contains('min-w-0')).toBe(true);
expect(label.classList.contains('flex-1')).toBe(true);
expect(label.classList.contains('truncate')).toBe(true);
// ボタンは逆に縮まない(名前に押し潰されない)。
expect(removeButton('a.txt').classList.contains('flex-shrink-0')).toBe(true);
});
it('件数が増えても一覧の高さに上限があり、内側だけがスクロールする', () => {
render(<ChatAttachmentList attachments={Array.from({ length: 12 }, (_, i) => attachment(`file-${i}.txt`))} onRemove={() => {}} />);
const list = screen.getByRole('list', { name: jaChat.attachment.listLabel });
// 上限の値まで押さえる。max-h- とだけ見ると max-h-none も通ってしまい、
// 入力欄そのものが伸び続ける(実測 216px → 540px)のを止められない。
expect(list.classList.contains('max-h-32')).toBe(true);
expect(list.classList.contains('overflow-y-auto')).toBe(true);
});
it('長い名前は 1 行に省略し、全文は title で読める', () => {
const long = 'とても長い添付ファイルの名前_2026-09-11_打ち合わせ議事録_最終版_改訂3.xlsx';
render(<ChatAttachmentList attachments={[attachment(long)]} onRemove={() => {}} />);
const label = screen.getByTitle(long);
expect(label).toBeInTheDocument();
expect(label.textContent).toBe(long);
expect(label.classList.contains('truncate')).toBe(true);
});
it('消すボタンは hover しなくても常に見えている', () => {
render(<ChatAttachmentList attachments={[attachment('a.txt')]} onRemove={() => {}} />);
const button = removeButton('a.txt');
expect(button).toBeVisible();
// hover で初めて現れる作りに戻したら赤くする
for (const hidden of ['opacity-0', 'group-hover:', 'invisible', 'hidden']) {
expect(button.className, `常時表示でなくなっている: ${hidden}`).not.toContain(hidden);
}
});
it('消すボタンの名前と吹き出しに、そのファイル名が入る', () => {
render(<ChatAttachmentList attachments={[attachment('見積書.xlsx'), attachment('memo.txt')]} onRemove={() => {}} />);
// 1 件ずつ、名前だけで取り分けられる(どれを消すのか読み上げで分かる)
const first = screen.getByRole('button', { name: removeLabel(jaChat, '見積書.xlsx') });
const second = screen.getByRole('button', { name: removeLabel(jaChat, 'memo.txt') });
expect(first).not.toBe(second);
expect(first.getAttribute('aria-label')).toContain('見積書.xlsx');
expect(first.getAttribute('title')).toBe(removeLabel(jaChat, '見積書.xlsx'));
expect(second.getAttribute('title')).toBe(removeLabel(jaChat, 'memo.txt'));
});
it('文言は辞書から引く(言語を英語にすると名前も英語になる)', async () => {
await i18n.changeLanguage('en');
render(<ChatAttachmentList attachments={[attachment('a.txt')]} onRemove={() => {}} />);
expect(screen.getByRole('list', { name: enChat.attachment.listLabel })).toBeInTheDocument();
expect(screen.getByRole('button', { name: removeLabel(enChat, 'a.txt') })).toBeInTheDocument();
// 画面直書きに差し替えたらここで赤くなる(日本語のまま出る)
expect(screen.queryByRole('button', { name: removeLabel(jaChat, 'a.txt') })).toBeNull();
});
it('消すボタンを押すと、その 1 件だけが消える', async () => {
const user = userEvent.setup();
render(<Harness initial={[attachment('a.txt'), attachment('b.txt'), attachment('c.txt')]} />);
await user.click(removeButton('b.txt'));
const names = screen.getAllByRole('listitem').map(row => row.textContent);
expect(names).toEqual(['a.txt', 'c.txt']);
});
it('最後の 1 件を消すと一覧そのものが消える', async () => {
const user = userEvent.setup();
render(<Harness initial={[attachment('only.txt')]} />);
await user.click(removeButton('only.txt'));
expect(screen.queryByRole('list')).toBeNull();
});
it('文言は日本語・英語の両方のロケールにあり、ファイル名の差し込み口を持つ', () => {
for (const [locale, catalog] of [['ja', jaChat], ['en', enChat]] as const) {
expect(catalog.attachment.listLabel, `${locale}/chat.json`).toBeTruthy();
expect(catalog.attachment.remove, `${locale}/chat.json`).toContain('{{name}}');
}
});
});
+61 -7
View File
@@ -1,18 +1,72 @@
/**
* 入力欄に添付したファイルの一覧(#1553)。
*
* **1 件 1 行の縦積み**で出す。以前は送信ボタンなどと同じ 1 行に札を折り返して
* 並べ、はみ出した分を縦スクロールに隠していた。名前が長いと札が行を食い尽くし、
* 消す操作が画面の外へ追い出される。消せることに気づかないまま送ってしまう、
* という申告はここから出ていた。
*
* 守ること:
* - 横に並べない(`flex-wrap` を戻さない)。1 行は 1 件分。
* - 名前は 1 行に収めて `truncate` で省略し、全文は `title` で読ませる。
* - 消すボタンは行の右端に固定し、**常に見える**色で置く。hover で初めて
* 見えるようにしない。
* - 添付が 0 件なら何も描かない(余白も増やさない)。
* - 件数が増えても入力欄が伸び続けないよう、一覧そのものに高さの上限と
* 内側のスクロールを持たせる(縦積みは崩さない)。
*/
import { useTranslation } from 'react-i18next';
export interface ChatAttachment {
name: string;
contentBase64: string;
}
export function ChatAttachmentList({ attachments, onRemove }: { attachments: ChatAttachment[]; onRemove: (name: string) => void }) {
const { t } = useTranslation('chat');
if (attachments.length === 0) return null;
return (
<div className="flex max-h-12 min-w-0 flex-1 flex-wrap gap-1 overflow-y-auto">
{attachments.map(attachment => (
<span key={attachment.name} className="inline-flex items-center gap-1 rounded border border-hairline bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-slate-700">
{attachment.name}
<button type="button" onClick={() => onRemove(attachment.name)} aria-label={`${attachment.name}を削除`} className="ml-0.5 text-slate-400 hover:text-slate-700">&times;</button>
</span>
))}
// 左右の余白は本文の入力欄と同じ px-3 に揃える(下のボタン行は px-2 だが、
// あちらは押しボタン自身が内側に余白を持っている)。文字どうしの左端が
// ずれていると、添付の行だけ本文からはみ出して見える。
//
// relative z-10 は入力欄の中の兄弟(本文・ボタン行)と揃えるため。水位の
// 表示は容器のいちばん下の高さ 2px の帯なので実際には重ならないが、
// 重ね順の指定を 1 つだけ外すと後から積むものの前後関係が読めなくなる。
<div className="relative z-10 px-3 pb-1">
<ul
data-testid="chat-attachment-list"
aria-label={t('attachment.listLabel')}
className="flex max-h-32 w-full flex-col gap-1 overflow-y-auto"
>
{attachments.map(attachment => (
<li
key={attachment.name}
data-testid="chat-attachment-row"
className="flex items-center gap-2 rounded border border-hairline bg-surface-2 py-1 pl-2 pr-1"
>
<span
className="min-w-0 flex-1 truncate font-mono text-2xs text-slate-700"
title={attachment.name}
>
{attachment.name}
</span>
<button
type="button"
data-testid="chat-attachment-remove"
onClick={() => onRemove(attachment.name)}
aria-label={t('attachment.remove', { name: attachment.name })}
title={t('attachment.remove', { name: attachment.name })}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded border border-hairline bg-canvas text-slate-600 transition-colors hover:border-red-300 hover:bg-red-50 hover:text-red-700 dark:hover:border-red-500 dark:hover:bg-red-950 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</li>
))}
</ul>
</div>
);
}
+117 -23
View File
@@ -1,27 +1,86 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
} from 'react';
import { useTranslation } from 'react-i18next';
import type { LocalTask } from '../../api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateLocalTask, type LocalTask } from '../../api';
import { useDraft } from '../../hooks/useDraft';
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
import { toBase64 } from '../../lib/fileAttachments';
import { isComposerLocked } from '../../lib/composerLock';
import { readAttachments, type Attachment } from '../../lib/fileAttachments';
import { ChatComposerPanel } from './ChatComposerPanel';
import { appendFileSelectionToBody } from '../../lib/file-selection';
import { useFileSelection } from '../../lib/file-selection-context';
interface ChatComposerProps {
/**
* いま走っている LLM 呼び出しの実プロンプトサイズ(SSE の promptProgress.total)。
* 5 秒巡回の値は「直前の呼び出しの結果」なので、ツールコールが続く間ずっと
* 動かない。次の呼び出しが始まった瞬間にこの値へ切り替える。
*/
livePromptTokens?: number;
task: LocalTask;
commentsLength: number;
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise<void>;
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>, opts?: { grounded?: boolean }) => Promise<void>;
onCancel?: () => Promise<void>;
/** 入力欄の下の行の左端に置く、このチャットそのものへの操作(issue #1433 原因 2 段 2)。 */
composerActions?: React.ReactNode;
}
export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatComposerProps) {
/** チャット画面へのドロップを入力欄へ届けるための窓口。 */
export interface ChatComposerHandle {
addFiles: (files: File[]) => Promise<void>;
}
export const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(
{ task, commentsLength, onSubmit, onCancel, livePromptTokens, composerActions },
ref,
) {
const { t } = useTranslation('chat');
// 選んだ範囲(#1463 段 2)。送る瞬間に本文へ展開して、そのあと外す。
const { selection: fileSelection, clearSelection: clearFileSelection } = useFileSelection();
const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`);
const [draft, setDraft] = useState(restoredDraft ?? '');
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [attachments, setAttachments] = useState<Attachment[]>([]);
// 非同期の読み込み中に来た次のドロップからも「今の添付一覧」が見えるようにする控え
const attachmentsRef = useRef<Attachment[]>([]);
const updateAttachments = useCallback((next: Attachment[]) => {
attachmentsRef.current = next;
setAttachments(next);
}, []);
const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [showPromptCoach, setShowPromptCoach] = useState(false);
const [grounded, setGrounded] = useState(false);
// コンテキスト構成 (#1300)。タスクごとに覚える設定なので、画面の手元に控えを
// 持たずタスクの値をそのまま映す。切り替えたらその場で保存し、再取得で戻す。
const qc = useQueryClient();
const compactContext = task.options?.compactContext === true;
// 保存に失敗すると、画面のボタンは元の状態へ戻るだけになる。理由が出ないと
// 「押しても効かない」としか見えないので、失敗はその場に文字で出す。
const [contextModeError, setContextModeError] = useState<string | null>(null);
const compactContextMutation = useMutation({
mutationFn: (next: boolean) => updateLocalTask(task.id, { options: { compactContext: next } }),
onMutate: () => { setContextModeError(null); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['localTask', task.id] });
qc.invalidateQueries({ queryKey: ['localTasks'] });
},
onError: (e: unknown) => {
setContextModeError(
e instanceof Error && e.message ? e.message : t('pane.contextModeSaveFailed'),
);
},
});
const composerRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
@@ -42,7 +101,13 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
const inputLocked = jobStatus === 'dispatching';
const awaitingToolApproval =
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
const composerLocked = inputLocked || awaitingToolApproval;
const awaitingPolicyApproval =
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'policy_approval';
// 人の確認(CAPTCHA)待ち(#1199 段階 2)。カードで「解決した」か「スキップ」を
// 押すまで、普通のメッセージは送れない(サーバーも 409 で断る)。
const awaitingCaptcha =
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'captcha';
const composerLocked = isComposerLocked(task.latestJob);
const releaseSubmitting = () => {
if (submitTimeoutRef.current) {
@@ -68,16 +133,24 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
};
}, []);
// ファイル選択・貼り付け・ドロップの 3 経路をここに集約する。
// 命名規則(image.png の日時化・重複回避)は新規タスク作成画面と共通。
const addFiles = useCallback(async (files: ArrayLike<File>) => {
if (files.length === 0) return;
// 連続ドロップでも名前がぶつからないよう、最新の添付一覧は ref から読む
const { added, failed } = await readAttachments(files, attachmentsRef.current);
if (added.length > 0) updateAttachments([...attachmentsRef.current, ...added]);
if (failed > 0) setSendError(t('pane.attachFailed', { count: failed }));
}, [t, updateAttachments]);
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const converted = await Promise.all(
Array.from(files).map(async f => ({ name: f.name, contentBase64: await toBase64(f) })),
);
setAttachments(prev => [...prev, ...converted]);
if (files) await addFiles(files);
};
useImperativeHandle(ref, () => ({ addFiles }), [addFiles]);
const removeAttachment = (name: string) => {
setAttachments(prev => prev.filter(a => a.name !== name));
updateAttachments(attachmentsRef.current.filter(a => a.name !== name));
};
const handleSubmit = async () => {
@@ -86,9 +159,26 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
setSubmitting(true);
submitBaselineRef.current = commentsLength;
try {
await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
const currentAttachments = attachments.length > 0 ? attachments : undefined;
/*
選んだ範囲は**送る瞬間に本文へ展開する**(#1463 段 2)。添付ファイルには
しない——添付は中身が必須で、送るとワークスペースの `input` に実ファイルが
落ちる。選ぶたびに断片ファイルが溜まることになる。サーバの受け口は
そのままで足りる。
*/
const body = fileSelection
? appendFileSelectionToBody(draft, fileSelection, (key, vars) => t(key, vars ?? {}) as string)
: draft;
if (grounded) {
await onSubmit(body, currentAttachments, { grounded: true });
} else {
await onSubmit(body, currentAttachments);
}
setDraft('');
setAttachments([]);
updateAttachments([]);
setGrounded(false);
// 送った札は外す。同じ範囲をもう一度添えたければ選び直す。
clearFileSelection();
clearDraft();
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
@@ -111,13 +201,7 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
}
if (files.length === 0) return;
e.preventDefault();
const converted = await Promise.all(
files.map(async f => {
const name = f.name === 'image.png' ? `paste-${Date.now()}.png` : f.name;
return { name, contentBase64: await toBase64(f) };
}),
);
setAttachments(prev => [...prev, ...converted]);
await addFiles(files);
};
const handleKeyDown = (e: KeyboardEvent) => {
@@ -140,6 +224,7 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
return (
<ChatComposerPanel
task={task}
livePromptTokens={livePromptTokens}
draft={draft}
attachments={attachments}
submitting={submitting}
@@ -150,6 +235,8 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
canInterject={canInterject}
inputLocked={inputLocked}
awaitingToolApproval={awaitingToolApproval}
awaitingPolicyApproval={awaitingPolicyApproval}
awaitingCaptcha={awaitingCaptcha}
composerLocked={composerLocked}
hasActiveJob={hasActiveJob}
jobStatus={jobStatus}
@@ -163,10 +250,17 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
onDraftChange={value => { setDraft(value); saveDraft(value); }}
showPromptCoach={showPromptCoach}
onTogglePromptCoach={() => setShowPromptCoach(value => !value)}
grounded={grounded}
onToggleGrounded={() => setGrounded(value => !value)}
compactContext={compactContext}
onToggleCompactContext={() => compactContextMutation.mutate(!compactContext)}
compactContextPending={compactContextMutation.isPending}
contextModeError={contextModeError}
onApplyPromptRewrite={value => { setDraft(value); saveDraft(value); }}
onResend={handleSubmit}
composerRef={composerRef}
fileInputRef={fileInputRef}
composerActions={composerActions}
/>
);
}
});
@@ -0,0 +1,103 @@
// @vitest-environment jsdom
//
// 添付一覧の置き場所(#1553)。
//
// 添付一覧は、送信・停止・LLM 選択・コンテキストの渡し方といった各種ボタンと
// **同じ行に置かない**。同じ行に居たせいで、名前が長い添付が行を食い尽くし、
// 消すボタンが行の外(スクロールの向こう側)へ追い出されていた。
//
// 文言は実物の辞書で初期化してから描く。鍵の文字列のまま検査すると、画面直書き
// に差し替えても緑のままになる。
import '../../test/dom-setup';
import { describe, it, expect, beforeAll } from 'vitest';
import { screen } from '@testing-library/react';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import jaChat from '../../i18n/locales/ja/chat.json';
import { renderWithProviders } from '../../test/render-helpers';
import { ChatComposerPanel } from './ChatComposerPanel';
import type { LocalTask } from '../../api';
beforeAll(async () => {
await i18n.use(initReactI18next).init({
lng: 'ja',
resources: { ja: { chat: jaChat } },
ns: ['chat'],
defaultNS: 'chat',
interpolation: { escapeValue: false },
});
});
const task = {
id: 7,
title: 't',
body: '',
status: 'succeeded',
latestJob: { id: 'job-1', status: 'succeeded' },
} as unknown as LocalTask;
function renderPanel(attachments: Array<{ name: string; contentBase64: string }>) {
const noop = () => {};
const noopAsync = async () => {};
return renderWithProviders(
<ChatComposerPanel
task={task}
draft=""
attachments={attachments}
submitting={false}
cancelling={false}
sendError={null}
isBusy={false}
isPending={false}
canInterject={false}
inputLocked={false}
awaitingToolApproval={false}
awaitingPolicyApproval={false}
awaitingCaptcha={false}
composerLocked={false}
hasActiveJob={false}
jobStatus="succeeded"
onSubmit={noopAsync}
onCancel={noopAsync}
onAttachClick={noop}
onRemoveAttachment={noop}
onFileChange={noopAsync}
onPaste={noopAsync}
onKeyDown={noop}
onDraftChange={noop}
showPromptCoach={false}
onTogglePromptCoach={noop}
grounded={false}
onToggleGrounded={noop}
compactContext={false}
onToggleCompactContext={noop}
compactContextPending={false}
contextModeError={null}
onApplyPromptRewrite={noop}
onResend={noopAsync}
composerRef={{ current: null }}
fileInputRef={{ current: null }}
/>,
);
}
describe('ChatComposerPanel: 添付一覧の置き場所 (#1553)', () => {
it('添付一覧は各種ボタンの行の外に出ている', () => {
renderPanel([{ name: 'a.txt', contentBase64: 'AAAA' }]);
const list = screen.getByRole('list', { name: jaChat.attachment.listLabel });
const buttonRow = screen.getByTestId('context-mode-toggle').parentElement;
expect(buttonRow).not.toBeNull();
// 送信ボタンも同じ行に居ること(前提が崩れていないかの確認)
expect(buttonRow!.textContent).toContain(jaChat.pane.send);
expect(buttonRow!.contains(list)).toBe(false);
// 添付の行は本文の入力欄と左端が揃う(下のボタン行の余白に引きずられない)
const attachmentRow = list.parentElement!;
expect(attachmentRow.classList.contains('px-3')).toBe(true);
expect(screen.getByRole('textbox').classList.contains('px-3')).toBe(true);
});
it('添付が 0 件のときは一覧を出さない', () => {
renderPanel([]);
expect(screen.queryByRole('list')).toBeNull();
});
});
@@ -0,0 +1,113 @@
// @vitest-environment jsdom
//
// コンテキスト残量ゲージの表示源に関する回帰テスト (#1011)。
//
// このゲージには **実測値しか入れない**(ユーザー確認済みの方針)。
// ツールコール中に数字を動かすには送信前の見積りを混ぜるしかないが、それは
// 「Preflight の数字と食い違う」という元の申告そのものを、同じ場所で
// 起こすことになる。1 つの表示が指す意味は 1 つに保つ。
//
// 実測値の入口は 2 つある:
// 1. SSE の promptProgress.total — いま走っている呼び出しの実プロンプト長。
// 次の呼び出しが始まった瞬間に出る(要 return_progress、既定オフ)
// 2. 5 秒巡回の latestJob.contextPromptTokens — 直前の呼び出しの実測値
// 1 が取れるときはそちらを優先する。どちらも実測値なので、混ぜても意味は
// 一貫している。
import '../../test/dom-setup';
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
import { ChatComposerPanel } from './ChatComposerPanel';
import type { LocalTask } from '../../api';
function makeTask(contextPromptTokens?: number): LocalTask {
return {
id: 42,
title: 't',
body: '',
status: 'running',
latestJob: {
id: 'job-1',
status: 'running',
contextPromptTokens,
contextLimitTokens: 100_000,
},
} as unknown as LocalTask;
}
function renderPanel(props: { task: LocalTask; livePromptTokens?: number }) {
// props は実際の interface に合わせる。足りないまま描画できてしまうと、
// 「配線ミスを捕まえる」ためのテスト自身が配線ミスを抱えることになる。
const noop = () => {};
// 非同期を宣言しているハンドラは Promise を返す形で渡す(型のずれを残さない)。
const noopAsync = async () => {};
return renderWithProviders(
<ChatComposerPanel
task={props.task}
livePromptTokens={props.livePromptTokens}
draft=""
attachments={[]}
submitting={false}
cancelling={false}
sendError={null}
isBusy={false}
isPending={false}
canInterject={false}
inputLocked={false}
awaitingToolApproval={false}
awaitingPolicyApproval={false}
awaitingCaptcha={false}
composerLocked={false}
hasActiveJob
jobStatus="running"
onSubmit={noopAsync}
onCancel={noopAsync}
onAttachClick={noopAsync}
onRemoveAttachment={noop}
onFileChange={noopAsync}
onPaste={noopAsync}
onKeyDown={noop}
onDraftChange={noop}
showPromptCoach={false}
onTogglePromptCoach={noop}
grounded={false}
onToggleGrounded={noop}
compactContext={false}
onToggleCompactContext={noop}
compactContextPending={false}
contextModeError={null}
onApplyPromptRewrite={noop}
onResend={noopAsync}
composerRef={{ current: null }}
fileInputRef={{ current: null }}
/>,
);
}
describe('ChatComposerPanel のコンテキスト残量ゲージ', () => {
it('巡回値しか無いときは、その実測値を表示する', () => {
renderPanel({ task: makeTask(12_000) });
expect(screen.getByText('12,000 / 100,000')).toBeInTheDocument();
});
it('走行中の呼び出しの実測長が来たら、巡回値より優先する', () => {
// 巡回値は「直前の呼び出し」。次の呼び出しが始まった時点で古くなるので、
// SSE から来た今の値を出す。これが無いと最大 5 秒だけ古い数字が残る。
renderPanel({ task: makeTask(12_000), livePromptTokens: 34_500 });
expect(screen.getByText('34,500 / 100,000')).toBeInTheDocument();
expect(screen.queryByText('12,000 / 100,000')).toBeNull();
});
it('まだ一度も測れていないときは、数字を作らない', () => {
// 0 と書くと「まだ測っていない」が「0 トークン使った」に化ける。
renderPanel({ task: makeTask(undefined) });
expect(screen.queryByText(/\/ 100,000$/)).toBeNull();
});
it('巡回値がまだ無くても、走行中の実測長だけで表示できる', () => {
// ジョブが始まったばかりで巡回値がまだ届いていない状態。実測長が
// 片方だけでも来ていれば、それは測れた事実なので出してよい。
renderPanel({ task: makeTask(undefined), livePromptTokens: 999 });
expect(screen.getByText('999 / 100,000')).toBeInTheDocument();
});
});
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
//
// issue #1099: 実行待ちのまま引き受け手が 1 台も居ないとき、その事実を画面に出す。
//
// ここで守るのは「配線」。サーバが算出した値(latestJob.noEligibleWorker)が
// 実行待ち表示まで届き、「まもなく開始します」を置き換えることを見る。この 1 行が
// 落ちると、利用者からは「ただ動かないタスク」に戻る。
import '../../test/dom-setup';
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
import { ChatComposerPanel } from './ChatComposerPanel';
import type { LocalTask } from '../../api';
const here = dirname(fileURLToPath(import.meta.url));
function makeTask(noEligibleWorker: boolean | null): LocalTask {
return {
id: 42,
title: 't',
body: '',
status: 'queued',
latestJob: {
id: 'job-1',
status: 'queued',
noEligibleWorker,
},
} as unknown as LocalTask;
}
function renderPanel(task: LocalTask) {
const noop = () => {};
const noopAsync = async () => {};
return renderWithProviders(
<ChatComposerPanel
task={task}
draft=""
attachments={[]}
submitting={false}
cancelling={false}
sendError={null}
isBusy={false}
isPending
canInterject={false}
inputLocked={false}
awaitingToolApproval={false}
awaitingPolicyApproval={false}
awaitingCaptcha={false}
composerLocked={false}
hasActiveJob
jobStatus="queued"
onSubmit={noopAsync}
onCancel={noopAsync}
onAttachClick={noop}
onRemoveAttachment={noop}
onFileChange={noopAsync}
onPaste={noopAsync}
onKeyDown={noop}
onDraftChange={noop}
showPromptCoach={false}
onTogglePromptCoach={noop}
grounded={false}
onToggleGrounded={noop}
compactContext={false}
onToggleCompactContext={noop}
compactContextPending={false}
contextModeError={null}
onApplyPromptRewrite={noop}
onResend={noopAsync}
composerRef={{ current: null }}
fileInputRef={{ current: null }}
/>,
);
}
describe('ChatComposerPanel: 引き受け手が居ない実行待ち (issue #1099)', () => {
it('引き受け手が居ないときは、その旨を実行待ち表示の場所に出す', () => {
renderPanel(makeTask(true));
const notice = screen.getByTestId('no-eligible-worker-notice');
expect(notice).toBeInTheDocument();
// 「まもなく開始します」の側は出さない(待てば始まる、と読ませない)
expect(notice.textContent).not.toContain('pane.queuedHint');
expect(notice.textContent).toContain('pane.noEligibleWorker');
});
it('引き受け手が居るときは、従来どおりの実行待ち表示のまま', () => {
renderPanel(makeTask(false));
expect(screen.queryByTestId('no-eligible-worker-notice')).toBeNull();
expect(screen.getByText(/pane\.queuedHint/)).toBeInTheDocument();
});
it('サーバが算出していない(null)ときも、従来どおりの表示', () => {
renderPanel(makeTask(null));
expect(screen.queryByTestId('no-eligible-worker-notice')).toBeNull();
});
it('文言は日本語・英語の両方のロケールにある(画面直書きにしない)', () => {
for (const locale of ['ja', 'en']) {
const path = join(here, '..', '..', 'i18n', 'locales', locale, 'chat.json');
const catalog = JSON.parse(readFileSync(path, 'utf8')) as { pane: Record<string, string> };
expect(catalog.pane.noEligibleWorker, `${locale}/chat.json`).toBeTruthy();
}
});
});
+152 -18
View File
@@ -4,12 +4,26 @@ import type { LocalTask } from '../../api';
import { PromptCoachPanel } from '../create/PromptCoachPanel';
import { ToolRequestApproval } from './ToolRequestApproval';
import { PackageRequestApproval } from './PackageRequestApproval';
import { SpawnRequestApproval } from './SpawnRequestApproval';
import { PolicyApproval } from './PolicyApproval';
import { LlmSelectionControl } from './LlmSelectionControl';
import { ChatAttachmentList } from './ChatAttachmentList';
import { ChatSelectionChip } from './ChatSelectionChip';
import { useFileSelection } from '../../lib/file-selection-context';
import { WaterContextGauge } from './WaterContextGauge';
interface ChatComposerPanelProps {
task: LocalTask;
/**
* いま走っている呼び出しの実プロンプトサイズ(SSE の promptProgress.total)。
* 無ければ 5 秒巡回の実測値を使う。
*
* **このゲージには実測値しか入れない**(#1011 でユーザー確認済み)。
* ツールコール中に数字を動かすには送信前の見積りを混ぜるしかないが、
* それは「Preflight の数字と食い違う」という元の申告そのものを、
* 同じ場所で起こすことになる。1 つの表示が指す意味は 1 つに保つ。
*/
livePromptTokens?: number;
draft: string;
attachments: Array<{ name: string; contentBase64: string }>;
submitting: boolean;
@@ -20,6 +34,9 @@ interface ChatComposerPanelProps {
canInterject: boolean;
inputLocked: boolean;
awaitingToolApproval: boolean;
awaitingPolicyApproval: boolean;
/** 人の確認(CAPTCHA)待ちで止まっている(#1199 段階 2) */
awaitingCaptcha: boolean;
composerLocked: boolean;
hasActiveJob: boolean;
jobStatus: string | null | undefined;
@@ -33,14 +50,35 @@ interface ChatComposerPanelProps {
onDraftChange: (value: string) => void;
showPromptCoach: boolean;
onTogglePromptCoach: () => void;
grounded: boolean;
onToggleGrounded: () => void;
/**
* コンテキスト構成 (#1300)。true = 圧縮(これまでのやり取りを要約して渡す)、
* false = シンプル(前回のやり取りをそのまま流す・既定)。タスクごとに覚える
* 設定なので、値はサーバー(タスクの設定)が正。
*/
compactContext: boolean;
onToggleCompactContext: () => void;
/** 切り替えの保存中。二度押しで往復が重ならないように押せなくする。 */
compactContextPending: boolean;
/** 切り替えの保存に失敗した理由。null なら失敗していない。 */
contextModeError: string | null;
onApplyPromptRewrite: (value: string) => void;
onResend: () => Promise<void>;
composerRef: RefObject<HTMLTextAreaElement>;
fileInputRef: RefObject<HTMLInputElement>;
/**
* 入力欄の下の行の**左端**に置く、このチャットそのものへの操作(issue #1433 原因 2 段 2)。
* 会話のタブ行の右端に居た「続きを流す」「このチャットを削除」がここへ移った。
* 取り返しのつかない削除を送信の隣に置かないため、置き場は左端に固定する。
* 渡されない画面(タスク一覧のチャット等)では、この区画ごと出ない。
*/
composerActions?: React.ReactNode;
}
export function ChatComposerPanel({
task,
livePromptTokens,
draft,
attachments,
submitting,
@@ -51,6 +89,8 @@ export function ChatComposerPanel({
canInterject,
inputLocked,
awaitingToolApproval,
awaitingPolicyApproval,
awaitingCaptcha,
composerLocked,
hasActiveJob,
jobStatus,
@@ -64,38 +104,81 @@ export function ChatComposerPanel({
onDraftChange,
showPromptCoach,
onTogglePromptCoach,
grounded,
onToggleGrounded,
compactContext,
onToggleCompactContext,
compactContextPending,
contextModeError,
onApplyPromptRewrite,
onResend,
composerRef,
fileInputRef,
composerActions,
}: ChatComposerPanelProps) {
const { t } = useTranslation('chat');
// ファイルの画面で選ばれた範囲(#1463 段 2)。提供者がいない画面では常に null。
const { selection: fileSelection, clearSelection: clearFileSelection } = useFileSelection();
// issue #1099: 実行待ちのまま引き受け手が 1 台も居ないとき、「まもなく開始します」
// と出し続けると利用者からはただ止まって見える。既存の実行待ち表示に相乗りして、
// 待っても始まらないことをその場で伝える(回っている印のスピナーも消す)。
const noEligibleWorker = isPending && task.latestJob?.noEligibleWorker === true;
return (
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-2.5" style={{ paddingBottom: 'calc(10px + env(safe-area-inset-bottom, 0px))' }}>
{hasActiveJob && (
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
canInterject
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
: isPending
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
}`}>
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
<div
data-testid={noEligibleWorker ? 'no-eligible-worker-notice' : undefined}
role={noEligibleWorker ? 'status' : undefined}
className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
noEligibleWorker
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-amber-800 dark:text-amber-200'
: canInterject
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
: isPending
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
}`}
>
{noEligibleWorker ? (
<svg className="w-3 h-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M12 9v4" /><path d="M12 17h.01" />
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
</svg>
) : (
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
)}
<span>
{noEligibleWorker
? t('pane.noEligibleWorker')
: canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}
</span>
</div>
)}
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
<PackageRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
<SpawnRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
{awaitingPolicyApproval && (
<PolicyApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
)}
{contextModeError && (
<div
role="alert"
data-testid="context-mode-error"
className="mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300"
>
⚠ {t('pane.contextModeSaveFailedPrefix', { reason: contextModeError })}
</div>
)}
{sendError && !isBusy && (
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300">
<span className="truncate">⚠ {sendError}</span>
<button
type="button"
onClick={() => void onResend()}
disabled={submitting}
disabled={submitting || composerLocked}
className="flex-shrink-0 px-2 h-6 bg-canvas border border-red-200 rounded text-[10px] font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/15 disabled:opacity-50"
>
{t('pane.resend')}
@@ -105,7 +188,7 @@ export function ChatComposerPanel({
<div className={`relative overflow-hidden rounded-xl border transition-shadow ${composerLocked ? 'border-hairline bg-surface' : 'border-hairline bg-canvas focus-within:border-accent focus-within:ring-2 focus-within:ring-accent-ring'}`}>
<WaterContextGauge
variant="fill"
promptTokens={task.latestJob?.contextPromptTokens}
promptTokens={livePromptTokens ?? task.latestJob?.contextPromptTokens}
limitTokens={task.latestJob?.contextLimitTokens}
/>
<textarea
@@ -116,10 +199,18 @@ export function ChatComposerPanel({
onPaste={e => void onPaste(e)}
rows={1}
disabled={composerLocked}
placeholder={awaitingToolApproval ? t('toolRequest.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
placeholder={awaitingPolicyApproval ? t('policyApproval.composerLocked') : awaitingToolApproval ? t('toolRequest.composerLocked') : awaitingCaptcha ? t('captcha.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
className="relative z-10 block w-full resize-none border-0 bg-transparent px-3 pt-2.5 pb-1 text-sm leading-6 text-slate-900 outline-none [field-sizing:content] min-h-6 max-h-48 overflow-y-auto disabled:text-slate-400 disabled:cursor-not-allowed"
/>
{/* 添付の一覧は各種ボタンとは別の行に置く(#1553)。同じ行に混ぜると
名前が長いときに札が行を食い尽くし、消すボタンが画面の外へ出る。 */}
<ChatAttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
<div className="relative z-10 flex flex-wrap items-center gap-1.5 px-2 pb-2">
{composerActions && (
<div data-testid="chat-composer-actions" className="flex shrink-0 items-center gap-1.5">
{composerActions}
</div>
)}
<input
ref={fileInputRef}
type="file"
@@ -145,19 +236,62 @@ export function ChatComposerPanel({
aria-expanded={showPromptCoach}
title={t('pane.evaluatePrompt')}
aria-label={t('pane.evaluatePrompt')}
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" />
<path d="m19 15 .75 2.25L22 18l-2.25.75L19 21l-.75-2.25L16 18l2.25-.75L19 15Z" />
</svg>
</button>
<ChatAttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
<button
type="button"
data-testid="grounded-toggle"
onClick={onToggleGrounded}
disabled={composerLocked || submitting}
aria-pressed={grounded}
title={t('pane.groundedToggle')}
aria-label={t('pane.groundedToggle')}
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent ${
grounded
? 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-300 dark:hover:bg-emerald-500/25'
: 'text-slate-500 hover:bg-surface hover:text-slate-900'
}`}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 4.5A2.5 2.5 0 0 1 8.5 2H19v16H8.5A2.5 2.5 0 0 0 6 20.5v-16Z" />
<path d="M6 20.5A1.5 1.5 0 0 0 7.5 22H19" />
<path d="m12 7 1.25 2.25L16 10l-2 1.75.5 2.75-2.5-1.25-2.5 1.25.5-2.75L8 10l2.75-.75L12 7Z" />
</svg>
</button>
<button
type="button"
data-testid="context-mode-toggle"
onClick={onToggleCompactContext}
disabled={composerLocked || submitting || compactContextPending}
aria-pressed={compactContext}
// いまどちらのモードかを文言に出す。アイコンの色だけでは、押す前に
// 現在の状態が読み取れない。
title={compactContext ? t('pane.contextModeToggleCompact') : t('pane.contextModeToggleSimple')}
aria-label={compactContext ? t('pane.contextModeToggleCompact') : t('pane.contextModeToggleSimple')}
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent ${
compactContext
? 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-300 dark:hover:bg-emerald-500/25'
: 'text-slate-500 hover:bg-surface hover:text-slate-900'
}`}
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 3v5h5" />
<path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" />
<path d="M12 7v5l3.5 2" />
</svg>
</button>
{/* 選んだ範囲の札。添付とは別の入れ物(#1463 段 2)。 */}
<ChatSelectionChip selection={fileSelection} onRemove={clearFileSelection} />
<LlmSelectionControl task={task} busy={isBusy} />
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
<WaterContextGauge
variant="label"
promptTokens={task.latestJob?.contextPromptTokens}
promptTokens={livePromptTokens ?? task.latestJob?.contextPromptTokens}
limitTokens={task.latestJob?.contextLimitTokens}
/>
{isBusy && onCancel ? (
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { fireEvent, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
const comment = {
id: 1,
taskId: 42,
author: 'user',
kind: 'comment',
body: 'see attachment',
attachments: ['report.pdf'],
createdAt: '2026-07-13T00:00:00.000Z',
injectedAt: null,
} as LocalTaskComment;
describe('ChatMessage attachments', () => {
it('opens the attachment preview instead of rendering a download link', () => {
const onAttachmentPreview = vi.fn();
renderWithProviders(<ChatMessage comment={comment} taskId={42} onAttachmentPreview={onAttachmentPreview} />);
const attachment = screen.getByRole('button', { name: 'report.pdf' });
expect(screen.queryByRole('link', { name: 'report.pdf' })).toBeNull();
fireEvent.click(attachment);
expect(onAttachmentPreview).toHaveBeenCalledWith('report.pdf');
});
it('keeps attachments hidden in shared chat', () => {
renderWithProviders(<ChatMessage comment={comment} taskId={42} hideAttachments />);
expect(screen.queryByText('report.pdf')).toBeNull();
});
});
@@ -0,0 +1,152 @@
// @vitest-environment jsdom
/**
* 確認(CAPTCHA)の案内が、チャットの発言として本当に描かれるか。
*
* 見るのは 2 つの経路。
* - いまの形(#1246): 道具の呼び出しの記録に載った検索先から、その行に案内が出る
* - 旧い形(#1199): 独立したカードだった頃の進捗コメントからも、同じ案内が出る。
* **#1246 より前に止まったタスクはこの形しか持っていない。**読み分けの 1 行を消すと、
* そのタスクは入力欄が閉じたまま、解除する口も無くなって永久に出られない
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
vi.mock('../embed/EmbedBlock', () => ({ EmbedBlock: () => null }));
vi.mock('react-i18next', async importOriginal => ({
...await importOriginal<typeof import('react-i18next')>(),
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => (
opts?.title !== undefined ? `${key}:${String(opts.title)}` : key
),
}),
}));
const comment = (body: string) => ({
id: 11,
taskId: 42,
author: 'agent',
kind: 'progress',
body,
attachments: [],
createdAt: '2026-08-19T10:00:20.000Z',
injectedAt: null,
} as unknown as LocalTaskComment);
const legacyCard = comment(JSON.stringify({ type: 'captcha_request', spaceId: 'space-a', seq: 2 }));
const toolRow = comment(JSON.stringify({
type: 'tool_call', name: 'WebSearch', callId: 'call-1', args: '{"query":"x"}', result: 'ok',
captchaBlockedEngines: ['google'], captchaSpaceId: 'space-a',
}));
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ available: true, captchaPending: true, pendingEngines: ['google'], novncPath: '/novnc/sess-1/vnc.html' }),
} as Response)));
});
afterEach(() => { vi.unstubAllGlobals(); });
describe('いまの形(道具の呼び出し行)', () => {
it('止められた検索先を載せた記録は、案内つきで描かれる', async () => {
renderWithProviders(<ChatMessage comment={toolRow} taskId={42} onOpenCaptchaScreen={vi.fn()} />);
expect(await screen.findByTestId('captcha-notice')).toBeInTheDocument();
// JSON がそのまま本文として出ていないこと
expect(screen.queryByText(/captchaBlockedEngines/)).toBeNull();
});
it('「開く」はワークスペースの識別子を上へ渡す', async () => {
const onOpen = vi.fn();
renderWithProviders(<ChatMessage comment={toolRow} taskId={42} onOpenCaptchaScreen={onOpen} />);
await userEvent.click(await screen.findByTestId('captcha-open-button'));
expect(onOpen).toHaveBeenCalledWith('space-a');
});
});
describe('旧い形(#1246 より前に出た確認カードのコメント)', () => {
it('同じ案内として描かれる', async () => {
renderWithProviders(<ChatMessage comment={legacyCard} taskId={42} onOpenCaptchaScreen={vi.fn()} />);
expect(await screen.findByTestId('captcha-notice')).toBeInTheDocument();
expect(screen.queryByText(/captcha_request/)).toBeNull();
});
it('止まったタスクを動かす口が残る', async () => {
renderWithProviders(<ChatMessage comment={legacyCard} taskId={42} onOpenCaptchaScreen={vi.fn()} />);
expect(await screen.findByTestId('captcha-resolve-button')).toBeInTheDocument();
expect(screen.getByTestId('captcha-skip-button')).toBeInTheDocument();
});
it('ボタンはワークスペースの識別子を上へ渡す', async () => {
const onOpen = vi.fn();
renderWithProviders(<ChatMessage comment={legacyCard} taskId={42} onOpenCaptchaScreen={onOpen} />);
await userEvent.click(await screen.findByTestId('captcha-open-button'));
expect(onOpen).toHaveBeenCalledWith('space-a');
});
it('開き先を渡さない画面ではボタンが出ない', async () => {
renderWithProviders(<ChatMessage comment={legacyCard} taskId={42} />);
await screen.findByTestId('captcha-notice');
expect(screen.queryByTestId('captcha-open-button')).toBeNull();
});
});
/**
* サブタスクが止められたときのカード(#1246)。
*
* サブタスクの道具の呼び出しは親のチャットに 1 行も書かれないので、案内をぶら下げる行が
* 無い。そのぶんカードを 1 枚出す。**どのサブタスクが止まっているかを書く**のが要で、
* 並んで走った 3 つが 3 つとも止められたら同じ見た目のカードが 3 枚並ぶ。
*/
describe('サブタスクが止まったときのカード(#1246)', () => {
const subtaskCard = comment(JSON.stringify({
type: 'captcha_request', spaceId: 'space-a', seq: 1,
subtask: { jobId: 'job-9', title: '価格を調べる' },
}));
it('どのサブタスクが止まっているかが読める', async () => {
renderWithProviders(<ChatMessage comment={subtaskCard} taskId={42} onOpenCaptchaScreen={vi.fn()} />);
expect(await screen.findByTestId('captcha-subtask')).toHaveTextContent('captcha.subtaskLabel:価格を調べる');
});
it('止まったサブタスクを動かす口が出る', async () => {
const onOpen = vi.fn();
renderWithProviders(<ChatMessage comment={subtaskCard} taskId={42} onOpenCaptchaScreen={onOpen} />);
await userEvent.click(await screen.findByTestId('captcha-open-button'));
expect(onOpen).toHaveBeenCalledWith('space-a');
expect(screen.getByTestId('captcha-resolve-button')).toBeInTheDocument();
});
});
/**
* カードは書かれた時点の未解決期間の番号(seq)を持っている。サーバーの番号が進んで
* いれば、そのカードの確認はもう終わっている——番号を渡していないと、あとから起きた
* 無関係な確認のたびに古いカードがぜんぶ色を取り戻して「開く」を出す(#1246)。
*/
describe('終わった確認のカードは静かになる(#1246)', () => {
it('サーバーの番号が進んでいたら、そのカードは済んだ扱いで描かれる', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ available: true, captchaPending: true, pendingEngines: ['google'], seq: 9, novncPath: '/novnc/sess-2/vnc.html' }),
} as Response)));
const oldCard = comment(JSON.stringify({ type: 'captcha_request', spaceId: 'space-a', seq: 2 }));
renderWithProviders(<ChatMessage comment={oldCard} taskId={42} onOpenCaptchaScreen={vi.fn()} />);
expect(await screen.findByTestId('captcha-past-check')).toBeInTheDocument();
expect(screen.queryByTestId('captcha-open-button')).toBeNull();
});
});
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
const comment = (kind: LocalTaskComment['kind'], body = 'completed body') => ({ id: 7, taskId: 42, author: 'agent', kind, body, createdAt: '2026-07-27T00:00:00.000Z', injectedAt: kind === 'interjection' ? '2026-07-27T00:00:01.000Z' : null }) as LocalTaskComment;
describe('ChatMessage dictionary eligibility', () => {
it.each(['request', 'comment', 'interjection', 'ask', 'result'] as const)('marks only the completed %s body as eligible', (kind) => {
const { container } = renderWithProviders(<ChatMessage comment={comment(kind)} taskId={42} />);
expect(container.querySelector('[data-dictionary-eligible="message-7"]')).toHaveTextContent('completed body');
});
it('does not mark an unacknowledged interjection as eligible', () => {
const pending = { ...comment('interjection'), injectedAt: null };
const { container } = renderWithProviders(<ChatMessage comment={pending} taskId={42} />);
expect(container.querySelector('[data-dictionary-eligible]')).toBeNull();
});
// 途中出力テキスト(思考ブロック・自由文 progress)はユーザーが読む本文なので
// 辞書対象。ただし更新中の 1 件(isStaleThinking でないもの)は選択中に本文が
// 伸びて選択が壊れるため除外する。メタデータ(ツール呼び出し・ステップ完了
// サマリ)は従来どおり対象外。
it('marks a settled thinking block as eligible', () => {
const { container } = renderWithProviders(
<ChatMessage comment={comment('progress', JSON.stringify({ type: 'thinking', text: '途中出力の本文' }))} taskId={42} isStaleThinking />,
);
expect(container.querySelector('[data-dictionary-eligible="message-7"]')).toHaveTextContent('途中出力の本文');
});
it('does not mark the still-updating thinking block as eligible', () => {
const { container } = renderWithProviders(
<ChatMessage comment={comment('progress', JSON.stringify({ type: 'thinking', text: 'updating' }))} taskId={42} />,
);
expect(container.querySelector('[data-dictionary-eligible]')).toBeNull();
});
it('marks free-form progress text as eligible', () => {
const { container } = renderWithProviders(
<ChatMessage comment={comment('progress', '中間報告の自由文')} taskId={42} />,
);
expect(container.querySelector('[data-dictionary-eligible="message-7"]')).toHaveTextContent('中間報告の自由文');
});
it('keeps movement-complete summary ineligible (metadata)', () => {
const body = JSON.stringify({ movement: 'plan', durationMs: 1200, tools: { Read: 2 } });
const { container } = renderWithProviders(<ChatMessage comment={comment('progress', body)} taskId={42} />);
expect(container.querySelector('[data-dictionary-eligible]')).toBeNull();
});
it('marks only the AskUser question as eligible', () => {
const body = JSON.stringify({
type: 'ask_questions',
questions: [{ question: 'デプロイ先はどちらにしますか?', options: [{ label: 'A' }, { label: 'B' }] }],
});
const { container } = renderWithProviders(<ChatMessage comment={comment('ask', body)} taskId={42} />);
expect(container.querySelector('[data-dictionary-eligible="message-7-question-0"]')?.textContent?.trim()).toBe('デプロイ先はどちらにしますか?');
expect(container.querySelector('[data-dictionary-eligible] button')).toBeNull();
});
});
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import i18n from '../../i18n';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
import { messageSpawnExcerpt } from './messageSpawn';
vi.mock('../embed/EmbedBlock', () => ({ EmbedBlock: () => null }));
const comment = (body: string, kind: 'progress' | 'event' = 'progress'): LocalTaskComment => ({
id: 11, taskId: 42, author: 'agent', kind, body, attachments: [],
createdAt: '2026-09-12T00:00:00Z', injectedAt: null,
});
beforeEach(async () => { await i18n.changeLanguage('ja'); });
describe('browser expiry and event comments (#1198)', () => {
it('renders Japanese guidance and treats labels as text, including quotes and newlines', () => {
const label = '社内 "A"\n<img src=x>';
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(JSON.stringify({
type: 'browser_session_expired', label, reason: 'HTTP 401',
}))} />);
expect(container.textContent).toContain('ログインが期限切れ');
expect(container.textContent).toContain(label);
expect(container.textContent).toContain('認証が必要');
expect(container.textContent).not.toContain('browser_session_expired');
expect(container.querySelector('img')).toBeNull();
});
it('renders English guidance when the UI language is English', async () => {
await i18n.changeLanguage('en');
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(JSON.stringify({
type: 'browser_session_expired', label: 'Portal', reason: 'redirected to login URL',
}))} />);
expect(container.textContent).toContain('Browser session');
expect(container.textContent).toContain('Portal');
expect(container.textContent).toContain('Sign in again');
expect(container.textContent).not.toContain('browser_session_expired');
});
it('also translates an exact legacy expiry notification', () => {
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(
'⚠️ Browser session "Portal" expired: logged-in selector not found. Re-login from Settings → Browser Sessions.',
)} />);
expect(container.textContent).toContain('ログインが期限切れ');
expect(container.textContent).toContain('ログイン済み状態を確認できませんでした');
expect(container.textContent).not.toContain('Re-login from Settings');
});
it.each([null, { label: 42, reason: 'x' }, { label: 'A', reason: { code: 401 } }])('keeps malformed notices readable: %j', data => {
const body = JSON.stringify(data === null ? null : { type: 'browser_session_expired', ...data });
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(body)} />);
expect(container.textContent).toContain(body);
});
it('renders event comments as system notes and does not make them user instructions', () => {
const event = comment('引き受けるワーカーがありません。', 'event');
renderWithProviders(<ChatMessage taskId={42} comment={event} onSpawn={vi.fn()} />);
expect(screen.getByRole('note', { name: 'システム通知' })).toHaveTextContent(event.body);
expect(messageSpawnExcerpt(event)).toBeNull();
expect(screen.queryByRole('button')).toBeNull();
});
});
@@ -0,0 +1,116 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, screen } from '@testing-library/react';
import i18n from '../../i18n';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTaskComment } from '../../api';
import { ABORT_CODES } from '../../../../src/engine/abort-codes';
import { ChatMessage } from './ChatMessage';
import { messageSpawnExcerpt } from './messageSpawn';
vi.mock('../embed/EmbedBlock', () => ({ EmbedBlock: () => null }));
const raw = '⚠️ 中断\n\nAborted: identical tool calls repeated.\n\nAdditional diagnostic text.';
const comment = (meta?: string | null, body = raw): LocalTaskComment => ({
id: 11, taskId: 42, author: 'agent', kind: 'result', body,
attachments: [], createdAt: '2026-09-12T00:00:00Z', injectedAt: null, ...{ meta },
});
const metadata = (overrides: Record<string, unknown> = {}) => JSON.stringify({
type: 'final_result', status: 'aborted', abortCode: 'tool_loop_detected', ...overrides,
});
beforeEach(async () => { await i18n.changeLanguage('ja'); });
describe('final result status cards (#1192)', () => {
it('shows a localized interruption and lets the user expand the complete original response', () => {
const source = comment(metadata());
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={source} />);
const card = screen.getByRole('region', { name: '実行を中断しました' });
expect(card).toHaveTextContent('同じツール操作が繰り返されたため中断しました');
expect(card).not.toHaveClass('bg-green-50');
const details = card.querySelector('details')!;
expect(details).not.toHaveAttribute('open');
expect(screen.getByText('Aborted: identical tool calls repeated.')).not.toBeVisible();
fireEvent.click(screen.getByText('詳細(元の応答)'));
expect(details).toHaveAttribute('open');
expect(screen.getByText('Aborted: identical tool calls repeated.')).toBeVisible();
expect(container).toHaveTextContent('Additional diagnostic text.');
expect(messageSpawnExcerpt(source)).toBe(raw);
expect(source.body).toBe(raw);
expect(card.querySelector('[data-dictionary-eligible]')).toHaveAttribute('data-dictionary-eligible', 'message-11');
});
it.each([
['error', 'llm_error', 'connection', '実行中にエラーが発生しました', '推論サーバーに接続できませんでした'],
['cancelled', 'cancelled', undefined, '実行をキャンセルしました', '利用者の操作によりキャンセルしました'],
['cancelled', 'deadline_exceeded', undefined, '実行をキャンセルしました', '実行時間の上限に達しました'],
])('separates %s from completed output (%s)', (status, abortCode, errorClass, title, reason) => {
renderWithProviders(<ChatMessage taskId={42} comment={comment(metadata({ status, abortCode, errorClass }))} />);
expect(screen.getByRole('region', { name: title })).toHaveTextContent(reason);
});
it.each(ABORT_CODES.filter(code => code !== 'worker_exception'))('localizes the server abort code %s', abortCode => {
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(metadata({
abortCode, ...(abortCode === 'llm_error' ? { errorClass: 'http' } : {}),
}))} />);
expect(container.querySelector('details')).not.toBeNull();
expect(container.querySelector('details')).not.toHaveAttribute('open');
expect(container.textContent).not.toContain('finalResult.');
expect(screen.queryByText('原因を特定できないエラーです')).toBeNull();
});
it.each([
'preflight_block', 'cancelled', 'idle_timeout', 'hard_cap', 'thinking_overflow',
'connection', 'http', 'stream', 'gateway_shutdown', 'gateway_timeout', 'budget_exhausted', 'rate_limited',
])('localizes communication failure %s', errorClass => {
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(metadata({
status: 'error', abortCode: 'llm_error', errorClass,
}))} />);
expect(container.querySelector('details')).not.toBeNull();
expect(container.textContent).not.toContain('finalResult.');
});
it.each([
'not-json', 'null', '[]', '{}',
metadata({ status: 'future-status' }), metadata({ type: 'future-result' }),
metadata({ abortCode: 'future-code' }), metadata({ abortCode: '__proto__' }),
metadata({ abortCode: 42 }), metadata({ abortCode: null }),
metadata({ status: 'error', abortCode: 'worker_exception' }),
metadata({ abortCode: 'llm_error', errorClass: 'unknown' }),
metadata({ abortCode: 'llm_error', errorClass: 'future-error' }),
metadata({ abortCode: 'llm_error' }),
])('keeps unknown or malformed metadata readable without opening details: %s', meta => {
renderWithProviders(<ChatMessage taskId={42} comment={comment(meta)} />);
expect(screen.getByRole('region', { name: '不明なエラー' })).toBeVisible();
expect(screen.getByText('Aborted: identical tool calls repeated.')).toBeVisible();
expect(screen.getByText('Additional diagnostic text.')).toBeVisible();
});
it.each([undefined, null, ''])('keeps legacy results without metadata readable (%s)', meta => {
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(meta)} />);
expect(container).toHaveTextContent('Aborted: identical tool calls repeated.');
expect(screen.queryByRole('region', { name: '不明なエラー' })).toBeNull();
});
it('does not interpret words in a successful response as a failure', () => {
const body = '✅ 完了\n\nThe cancelled operation is now fixed.';
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(metadata({ status: 'completed' }), body)} />);
expect(container).toHaveTextContent('The cancelled operation is now fixed.');
expect(container.querySelector('.bg-green-50')).not.toBeNull();
expect(container.querySelector('details')).toBeNull();
});
it('switches status, reason and details to English', async () => {
await i18n.changeLanguage('en');
renderWithProviders(<ChatMessage taskId={42} comment={comment(metadata())} />);
expect(screen.getByRole('region', { name: 'Execution stopped' })).toHaveTextContent('The same tool operations repeated without progress.');
expect(screen.getByText('Details (original response)')).toBeVisible();
});
it('keeps raw HTML in the original response escaped', () => {
const { container } = renderWithProviders(<ChatMessage taskId={42} comment={comment(
metadata({ abortCode: 'future-code' }), '<script>window.leak = true</script>\n\nOriginal response',
)} />);
expect(container.querySelector('script')).toBeNull();
expect(container).toHaveTextContent('Original response');
});
});
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
/**
* ツール履歴からファイルを開く導線が、チャットの実行中の行にも届いているか
* (issue #989 要望③)。
*
* 実行中に流れる tool_call コメントは ChatMessage → ProgressCard の中で
* ToolCallsSection に化ける。この受け渡しが 1 段でも抜けると、ステップが
* 終わるまでファイル名が押せない。
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { fireEvent, screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
vi.mock('../embed/EmbedBlock', () => ({ EmbedBlock: () => null }));
const toolCallComment = {
id: 9,
taskId: 42,
author: 'agent',
kind: 'progress',
body: JSON.stringify({
type: 'tool_call',
callId: 'call-1',
movement: 'execute',
name: 'Write',
args: JSON.stringify({ file_path: 'output/report.md', content: 'x' }),
result: 'ok',
isError: false,
durationMs: 10,
}),
attachments: [],
createdAt: '2026-08-07T10:00:20.000Z',
injectedAt: null,
} as unknown as LocalTaskComment;
describe('ChatMessage のツール行', () => {
it('実行中に流れる書き込みの行でも、ファイル名を押せる', () => {
const onOpenToolFile = vi.fn();
renderWithProviders(<ChatMessage comment={toolCallComment} taskId={42} onOpenToolFile={onOpenToolFile} />);
fireEvent.click(screen.getByRole('button', { name: 'output/report.md' }));
expect(onOpenToolFile).toHaveBeenCalledWith({
section: 'output',
path: 'report.md',
wroteFile: true,
at: '2026-08-07T10:00:20.000Z',
});
});
it('開く先が無ければリンクにしない', () => {
renderWithProviders(<ChatMessage comment={toolCallComment} taskId={42} />);
expect(screen.queryByRole('button', { name: 'output/report.md' })).toBeNull();
});
});
+190 -47
View File
@@ -1,10 +1,21 @@
import { ContextRecoveryCard, parseContextRecoveryMetadata } from './ContextRecoveryCard';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalTaskComment, getLocalFileRawUrl } from '../../api';
import { LocalTaskComment } from '../../api';
import { MarkdownPreview } from '../files/FilePreview';
import { MarkdownText } from '../../lib/markdown-text';
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection';
import { AskUserCard, tryParseAskQuestions } from './AskUserCard';
import { CaptchaNotice } from './CaptchaNotice';
import { tryParseCaptchaRequest } from './captchaComments';
import { parseBrowserSessionExpired, browserSessionReasonKey } from './browserSessionComments';
import { ToolCallsSection, parseToolCallComment, type ToolCallFileOpen } from './ToolCallsSection';
import { parseJobRetry } from './movementHistory';
import { parseFinalResultMetadata } from './finalResultComments';
import { FinalResultCard } from './FinalResultCard';
import { isThinkingComment } from './thinkingUtils';
import { messageSpawnExcerpt } from './messageSpawn';
import { MessageSpawnMenu } from './MessageSpawnMenu';
import type { SpawnRequest } from '../../lib/spawnIntent';
// We delegate spacing to MarkdownText's built-in COMPACT_PROSE default
// (4px-ish paragraph margins, leading-snug, `!important` to beat the
@@ -15,18 +26,38 @@ import { parseJobRetry } from './movementHistory';
interface ChatMessageProps {
comment: LocalTaskComment;
taskId: number;
/** Override the base URL used for inline Markdown images (e.g. for shared view) */
/** Override the base URL used for inline Markdown images. */
imageBaseUrl?: string;
/** When true, this thinking comment has been superseded — show static dot instead of spinner */
isStaleThinking?: boolean;
/**
* When true, hide comment attachment download chips. Attachments live under the
* task's `input/` directory, which is NOT served by the public share endpoint
* (`/api/shared/:token` serves only `output/`). The chips would otherwise point
* at the authenticated `/api/local/...input` URL and break / leak scope on the
* shared page. Shared view passes this as true.
*/
/** When true, hide comment attachment download chips. */
hideAttachments?: boolean;
/**
* When true, a structured ask comment renders an answerable card.
* Default is read-only: only the live feed knows whether the task is still
* waiting_human and unanswered (findInteractiveAskId) — every other render
* site (movement-group history, timeline, dialogs) shows a closed card so a
* stale card can't re-trigger the task with one tap after reload.
*/
askInteractive?: boolean;
onAttachmentPreview?: (name: string) => void;
onOpenToolFile?: (ref: ToolCallFileOpen) => void;
/**
* 人の確認(CAPTCHA)のカードのボタンを押したとき(issue #1199 段階 1)。
*
* 渡された画面でだけボタンが出る。押すとタスク詳細のブラウザタブへ移り、そのワークスペース
* の確認画面が選ばれる。**押せるかどうかの最終判定はサーバー**(`GET /captcha-pool`)で、
* 権限が無ければカード側がボタンなしの表示に落ちる。
*/
onOpenCaptchaScreen?: (spaceId: string | null) => void;
/**
* 発言ごとの操作メニューから切り出すとき(issue #993 段階 2 の S2-1)。
*
* 渡されなかった画面ではメニューそのものを出さない。押せて何も起きないボタンを
* 並べないためであり、**切り出せる権限があるかの判定でもある**——呼び出し側は
* 段階 1 と同じ `canEditInSpace` の結果でこれを渡す/渡さないを決める。
*/
onSpawn?: (payload: SpawnRequest) => void;
}
interface ProgressData {
@@ -259,7 +290,7 @@ function Spinner() {
);
}
function ProgressPill({ icon, children, variant = 'inline' }: { icon: React.ReactNode; children: React.ReactNode; variant?: 'inline' | 'block' }) {
export function ProgressPill({ icon, children, variant = 'inline', spawnMenu }: { icon: React.ReactNode; children: React.ReactNode; variant?: 'inline' | 'block'; spawnMenu?: React.ReactNode }) {
// inline: short one-liner (movement summary / fallback) — pill shape
// block: potentially multi-line thinking text — rounded rectangle to avoid weird ellipse corners
const shapeCls = variant === 'block'
@@ -273,16 +304,17 @@ function ProgressPill({ icon, children, variant = 'inline' }: { icon: React.Reac
// (movement summary string) are single-line, so they don't need it
// either.
return (
<div className="flex justify-start">
<div className="flex justify-start items-start gap-1">
<div className={`inline-flex gap-2 bg-slate-50 border border-slate-200 text-xs text-slate-600 ${shapeCls}`}>
<span className={`flex-shrink-0 ${variant === 'block' ? 'mt-0.5' : ''}`}>{icon}</span>
<div className="break-words min-w-0">{children}</div>
</div>
{spawnMenu}
</div>
);
}
function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; isStaleThinking?: boolean }) {
function ProgressCard({ comment, taskId, isStaleThinking, spawnMenu, onOpenToolFile, onOpenCaptchaScreen }: { comment: LocalTaskComment; taskId: number; isStaleThinking?: boolean; spawnMenu?: React.ReactNode; onOpenToolFile?: (ref: ToolCallFileOpen) => void; onOpenCaptchaScreen?: (spaceId: string | null) => void }) {
const { t } = useTranslation('chat');
const retry = parseJobRetry(comment.body);
if (retry) {
@@ -291,6 +323,20 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
: t('movementMap.jobRetry', { attempt: retry.nextAttempt, max: retry.maxAttempts });
return <ProgressPill icon={<span className="text-amber-600">{'↻'}</span>}>{text}</ProgressPill>;
}
const expired = parseBrowserSessionExpired(comment.body);
if (expired) {
const reasonKey = browserSessionReasonKey(expired.reason);
const reason = reasonKey ? t(`message.browserSessionReasons.${reasonKey}`) : expired.reason;
return (
<ProgressPill icon={<span className="text-amber-600">{'⚠'}</span>} variant="block">
<div role="note" aria-label={t('message.browserSessionExpiredTitle')} className="space-y-1 whitespace-pre-wrap">
<p className="font-medium">{t('message.browserSessionExpired', { label: expired.label })}</p>
<p>{t('message.browserSessionReason', { reason })}</p>
<p>{t('message.browserSessionRelogin')}</p>
</div>
</ProgressPill>
);
}
// Interjection ack → minimal centered confirmation
const ackData = tryParseInterjectionAck(comment.body);
if (ackData) {
@@ -309,7 +355,14 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
// movement-complete arrives (live tool calls during running movement).
const toolCall = parseToolCallComment(comment.body);
if (toolCall) {
return <ToolCallsSection toolCalls={[{ ...toolCall, ts: comment.createdAt }]} />;
return (
<ToolCallsSection
toolCalls={[{ ...toolCall, ts: comment.createdAt }]}
taskId={taskId}
{...(onOpenToolFile ? { onOpenFile: onOpenToolFile } : {})}
{...(onOpenCaptchaScreen ? { onOpenCaptchaScreen } : {})}
/>
);
}
// Checklist progress → dedicated card (center, retained as per decision)
@@ -318,6 +371,32 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
return <ChecklistCard comment={comment} />;
}
/*
独立したカードの確認コメント。行き先は 2 通り。
- サブタスクの検索が止められたとき(いまも書かれる)。サブタスクの呼び出し行は
このチャットに 1 行も出ないので、案内をぶら下げる行が無い(#1246)
- #1246 より前に本体のタスクが出したもの。**既に止まっているタスクはこの形しか
持っていない**ので読めなくすると、入力欄が閉じたまま解除する口が消える
止められた検索先は記録に無いので、画面に残っている分をそのまま見せる。代わりに
未解決期間の番号(seq)を渡し、あとから出た別の確認で蒸し返されないようにする。
*/
const captchaData = tryParseCaptchaRequest(comment.body);
if (captchaData) {
return (
<div className="flex justify-center">
<div className="max-w-[90%] w-full">
<CaptchaNotice
spaceId={captchaData.spaceId}
taskId={taskId}
cardSeq={captchaData.seq}
{...(captchaData.subtask ? { subtaskTitle: captchaData.subtask.title } : {})}
{...(onOpenCaptchaScreen ? { onOpenCaptchaScreen } : {})}
/>
</div>
</div>
);
}
// Thinking / in-flight LLM text \u2014 MD render so streaming output that
// contains lists / fenced code / inline backticks looks right.
const thinking = tryParseThinking(comment.body);
@@ -325,9 +404,13 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
const icon = isStaleThinking
? <span className="text-slate-400">{'\u2026'}</span>
: <Spinner />;
// \u9014\u4e2d\u51fa\u529b\u3082\u30e6\u30fc\u30b6\u30fc\u304c\u8aad\u3080\u672c\u6587\u306a\u306e\u3067\u8f9e\u66f8\u5bfe\u8c61\u3002\u305f\u3060\u3057\u66f4\u65b0\u4e2d\u306e 1 \u4ef6
// (isStaleThinking \u3067\u306a\u3044\u3082\u306e) \u306f\u9078\u629e\u4e2d\u306b\u672c\u6587\u304c\u4f38\u3073\u3066\u9078\u629e\u304c\u58ca\u308c\u308b\u305f\u3081\u9664\u5916\u3002
return (
<ProgressPill icon={icon} variant="block">
<MarkdownText text={thinking.text} />
<ProgressPill icon={icon} variant="block" spawnMenu={spawnMenu}>
<div data-dictionary-eligible={isStaleThinking ? 'message-' + comment.id : undefined}>
<MarkdownText text={thinking.text} />
</div>
</ProgressPill>
);
}
@@ -341,29 +424,29 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
return <ProgressPill icon={<span className="text-green-600">{'\u2713'}</span>}>{text}</ProgressPill>;
}
// Fallback: free-form text, MD render
// Fallback: free-form text, MD render. \u4e00\u5ea6\u304d\u308a\u306e\u6295\u7a3f\u3067\u66f4\u65b0\u3055\u308c\u306a\u3044\u306e\u3067\u8f9e\u66f8\u5bfe\u8c61\u3002
return (
<ProgressPill icon={<span className="text-slate-400">{'\u2022'}</span>}>
<MarkdownText text={comment.body} />
<ProgressPill icon={<span className="text-slate-400">{'\u2022'}</span>} spawnMenu={spawnMenu}>
<div data-dictionary-eligible={'message-' + comment.id}>
<MarkdownText text={comment.body} />
</div>
</ProgressPill>
);
}
/**
* Download chips for files the user attached to a comment. Files live under the
* task's input/ dir; we link to the raw-file endpoint with a download attribute.
* Preview chips for files the user attached to a comment. Files live under the
* task's input/ dir and are opened by the task-level FilePreview controller.
*/
function CommentAttachments({ attachments, taskId }: { attachments?: string[]; taskId: number }) {
function CommentAttachments({ attachments, onPreview }: { attachments?: string[]; onPreview?: (name: string) => void }) {
if (!attachments || attachments.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-1.5">
{attachments.map(name => (
<a
<button
type="button"
key={name}
href={getLocalFileRawUrl(taskId, 'input', name)}
download={name}
target="_blank"
rel="noopener noreferrer"
onClick={() => onPreview?.(name)}
title={name}
className="inline-flex items-center gap-1 max-w-[200px] px-2 py-1 rounded-md bg-white/70 dark:bg-slate-900/40 border border-slate-200 dark:border-slate-600 text-2xs text-slate-700 dark:text-slate-200 hover:bg-white dark:hover:bg-slate-800 hover:border-accent transition-colors"
>
@@ -371,19 +454,51 @@ function CommentAttachments({ attachments, taskId }: { attachments?: string[]; t
<path d="M10 2.5 4.2 8.3a2 2 0 0 0 2.8 2.8l5.3-5.3a3.2 3.2 0 0 0-4.5-4.5L2.4 7.2" />
</svg>
<span className="truncate">{name}</span>
</a>
</button>
))}
</div>
);
}
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hideAttachments }: ChatMessageProps) {
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hideAttachments, askInteractive = false, onAttachmentPreview, onOpenToolFile, onOpenCaptchaScreen, onSpawn }: ChatMessageProps) {
const { t } = useTranslation('chat');
const { kind, author, body, createdAt } = comment;
// 発言ごとの操作メニュー(issue #993 S2-1)。切り出せる本文が無い発言(ツール履歴・
// 完了サマリ・選択肢つき質問カード・更新中の途中出力)では null になり、ボタンごと
// 出ない。
const spawnExcerpt = onSpawn
? messageSpawnExcerpt(comment, { live: isThinkingComment(comment) && !isStaleThinking })
: null;
const spawnMenu = onSpawn && spawnExcerpt ? (
<MessageSpawnMenu
excerpt={spawnExcerpt}
sourceRef={{ kind: 'chat', taskId }}
menuLabel={t('messageMenu.messageLabel', { author, time: new Date(createdAt).toLocaleString() })}
onSpawn={onSpawn}
className="flex-shrink-0"
/>
) : null;
if (kind === 'event') {
return (
<ProgressPill icon={<span>{'ⓘ'}</span>} variant="block">
<div role="note" aria-label={t('message.systemEvent')}>
<div className="mb-1 font-medium">{t('message.systemEvent')}</div>
<MarkdownText text={body} />
</div>
</ProgressPill>
);
}
if (kind === 'progress' && author === 'system') {
const recovery = parseContextRecoveryMetadata(comment.meta);
if (recovery) return <ContextRecoveryCard record={recovery} />;
}
// Progress card (center)
if (kind === 'progress') {
return <ProgressCard comment={comment} isStaleThinking={isStaleThinking} />;
return <ProgressCard comment={comment} taskId={taskId} isStaleThinking={isStaleThinking} spawnMenu={spawnMenu} {...(onOpenToolFile ? { onOpenToolFile } : {})} {...(onOpenCaptchaScreen ? { onOpenCaptchaScreen } : {})} />;
}
// Interjection (user message sent during running)
@@ -392,11 +507,14 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hi
return (
<div className="flex justify-end">
<div className="max-w-[82%] bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-slate-900 rounded-2xl rounded-br-md px-4 py-3">
<div className="text-2xs text-amber-500 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="text-2xs text-amber-500">
{author} · {new Date(createdAt).toLocaleString()}
</div>
{spawnMenu}
</div>
<MarkdownText text={body} />
{!hideAttachments && <CommentAttachments attachments={comment.attachments} taskId={taskId} />}
<div data-dictionary-eligible={comment.injectedAt ? 'message-' + comment.id : undefined}><MarkdownText text={body} /></div>
{!hideAttachments && <CommentAttachments attachments={comment.attachments} onPreview={onAttachmentPreview} />}
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}>
{isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
</div>
@@ -410,11 +528,14 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hi
return (
<div className="flex justify-end">
<div className="max-w-[82%] bg-slate-100 text-slate-900 rounded-2xl rounded-br-md px-4 py-3">
<div className="text-2xs text-slate-400 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="text-2xs text-slate-400">
{author} · {new Date(createdAt).toLocaleString()}
</div>
{spawnMenu}
</div>
<MarkdownText text={body} />
{!hideAttachments && <CommentAttachments attachments={comment.attachments} taskId={taskId} />}
<div data-dictionary-eligible={'message-' + comment.id}><MarkdownText text={body} /></div>
{!hideAttachments && <CommentAttachments attachments={comment.attachments} onPreview={onAttachmentPreview} />}
</div>
</div>
);
@@ -422,29 +543,51 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hi
// Agent ask (left, yellow)
if (kind === 'ask') {
// AskUser (park) の構造化質問はカードで描画し、タップで答えられるようにする。
// 従来の自由文 ASK は従来どおりの吹き出し。
const askQuestions = tryParseAskQuestions(body);
// 辞書の文脈も各質問文に限定する。見出し・選択肢・操作文言を巻き込まない。
if (askQuestions) {
return (
<AskUserCard taskId={taskId} commentId={comment.id} payload={askQuestions} readOnly={!askInteractive} />
);
}
return (
<div className="flex justify-start">
<div className="max-w-[82%] bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 text-slate-900 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm">
<div className="text-2xs text-amber-500 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="text-2xs text-amber-500">
{author} · {new Date(createdAt).toLocaleString()}
</div>
{spawnMenu}
</div>
<MarkdownText text={body} />
<div data-dictionary-eligible={'message-' + comment.id}><MarkdownText text={body} /></div>
</div>
</div>
);
}
// Agent result (left, green) - render with Markdown
// Successful/legacy results keep the original result presentation.
if (kind === 'result') {
const content = (
<div data-dictionary-eligible={'message-' + comment.id} className="text-sm leading-relaxed prose prose-sm prose-slate dark:prose-invert max-w-none">
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} escapeRawHtml />
</div>
);
const finalResult = parseFinalResultMetadata(comment.meta);
if (finalResult) {
return <FinalResultCard result={finalResult} author={author} createdAt={createdAt} menu={spawnMenu}>{content}</FinalResultCard>;
}
return (
<div className="flex justify-start">
<div className="w-full bg-green-50 dark:bg-green-500/15 border border-green-200 dark:border-green-500/30 text-slate-900 rounded-xl px-4 py-3 shadow-sm">
<div className="text-2xs text-green-500 mb-1.5">
{author} · {new Date(createdAt).toLocaleString()}
</div>
<div className="text-sm leading-relaxed prose prose-sm prose-slate dark:prose-invert max-w-none">
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} escapeRawHtml />
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="text-2xs text-green-500">
{author} · {new Date(createdAt).toLocaleString()}
</div>
{spawnMenu}
</div>
{content}
</div>
</div>
);
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { act, fireEvent, screen } from '@testing-library/react';
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTask, LocalTaskComment } from '../../api';
import type { JobStreamState } from '../../hooks/useJobStream';
import i18n from '../../i18n';
const { lookupDictionaryMock } = vi.hoisted(() => ({ lookupDictionaryMock: vi.fn() }));
vi.mock('../../api/dictionary', async (importOriginal) => ({
...(await importOriginal<typeof import('../../api/dictionary')>()),
lookupDictionary: lookupDictionaryMock,
}));
vi.mock('../pets/AnswerWorkerIdentity', () => ({
AnswerWorkerIdentity: () => null,
}));
import { ChatMessageFeed } from './ChatMessageFeed';
const task = { id: 42, title: 'Task', pieceName: 'chat', latestJob: { status: 'succeeded' } } as LocalTask;
const comment = { id: 1, taskId: 42, author: 'user', kind: 'comment', body: 'completed message body', createdAt: '2026-07-27T00:00:00Z', injectedAt: null } as LocalTaskComment;
const stream = { promptProgress: null, streamingText: '', toolCallStream: {}, connected: false, delegateStreams: {}, llmState: null } as unknown as JobStreamState;
const rect = { top: 10, right: 60, bottom: 30, left: 10, width: 50, height: 20, x: 10, y: 10, toJSON: () => ({}) } as DOMRect;
beforeAll(async () => i18n.changeLanguage('en'));
beforeEach(() => { lookupDictionaryMock.mockReset(); });
const beginLookup = () => {
let signal: AbortSignal | undefined;
lookupDictionaryMock.mockImplementation((request, options) => {
signal = options.signal;
return new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => {
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true });
});
});
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment]} jobStream={stream} dictionaryEnabled />);
const body = screen.getByText('completed message body').closest('[data-dictionary-eligible]')!;
const text = body.firstChild?.firstChild ?? body.firstChild!;
vi.spyOn(window, 'getSelection').mockReturnValue({
rangeCount: 1, isCollapsed: false, toString: () => 'completed',
getRangeAt: () => ({ commonAncestorContainer: text, startContainer: text, endContainer: text, startOffset: 0, endOffset: 9, getBoundingClientRect: () => rect }),
removeAllRanges: vi.fn(),
} as unknown as Selection);
act(() => document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })));
fireEvent.click(screen.getByRole('button', { name: 'Explain this selection' }));
expect(signal).toBeDefined();
return { rendered, getSignal: () => signal };
};
describe('ChatMessageFeed dictionary abort integration', () => {
it('aborts the actual lookup signal when the task changes', () => {
const { rendered, getSignal } = beginLookup();
const signal = getSignal();
expect(signal?.aborted).toBe(false);
rendered.rerender(<ChatMessageFeed task={{ ...task, id: 43 }} comments={[comment]} jobStream={stream} dictionaryEnabled />);
expect(signal?.aborted).toBe(true);
});
it('aborts the actual lookup signal when the feed unmounts', () => {
const { rendered, getSignal } = beginLookup();
const signal = getSignal();
expect(signal?.aborted).toBe(false);
rendered.unmount();
expect(signal?.aborted).toBe(true);
});
});
@@ -0,0 +1,152 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import i18n from '../../i18n';
import { useEffect } from 'react';
import { act, fireEvent, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTask, LocalTaskComment } from '../../api';
import type { JobStreamState } from '../../hooks/useJobStream';
const popoverUnmounted = vi.fn();
vi.mock('../files/DictionaryPopover', () => ({
DictionaryPopover: ({ selection }: { selection: { spawnText: string; dictionaryText: string | null; context?: string; rect: { top: number } } }) => {
useEffect(() => () => popoverUnmounted(), []);
return <div role="dialog" data-context={selection.context} data-top={selection.rect.top}>{selection.spawnText}</div>;
},
}));
import { ChatMessageFeed } from './ChatMessageFeed';
const task = { id: 42, title: 'Task', pieceName: 'chat', latestJob: { status: 'running' } } as LocalTask;
const comment = { id: 1, taskId: 42, author: 'user', kind: 'comment', body: 'completed message body', createdAt: '2026-07-27T00:00:00Z', injectedAt: null } as LocalTaskComment;
const stream = { promptProgress: null, streamingText: 'live stream excluded', toolCallStream: {}, connected: true, delegateStreams: {}, llmState: null } as unknown as JobStreamState;
const rect = { top: 10, right: 60, bottom: 30, left: 10, width: 50, height: 20, x: 10, y: 10, toJSON: () => ({}) } as DOMRect;
describe('ChatMessageFeed dictionary integration', () => {
it('opens for a completed body and unmounts the lookup UI when mode turns off', () => {
popoverUnmounted.mockClear(); const removeAllRanges = vi.fn();
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment]} jobStream={stream} dictionaryEnabled />);
const body = screen.getByText('completed message body').closest('[data-dictionary-eligible]')!;
const text = body.firstChild?.firstChild ?? body.firstChild!;
vi.spyOn(window, 'getSelection').mockReturnValue({
rangeCount: 1, isCollapsed: false, toString: () => 'completed',
getRangeAt: () => ({ commonAncestorContainer: text, startContainer: text, endContainer: text, startOffset: 0, endOffset: 9, getBoundingClientRect: () => rect }),
removeAllRanges,
} as unknown as Selection);
act(() => document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })));
expect(screen.getByRole('dialog').textContent).toContain('completed');
expect(screen.getByRole('dialog').getAttribute('data-context')).toBe('completed message body');
expect(screen.getByText('live stream excluded').closest('[data-dictionary-eligible]')).toBeNull();
rendered.rerender(<ChatMessageFeed task={task} comments={[comment]} jobStream={stream} dictionaryEnabled={false} />);
expect(screen.queryByRole('dialog')).toBeNull();
expect(popoverUnmounted).toHaveBeenCalledTimes(1);
// 選択メニューを OFF にしても、利用者が選んだ文字は残す (issue #1259)。
expect(removeAllRanges).not.toHaveBeenCalled();
});
// issue #1259: メニューを閉じる経路で `removeAllRanges()` を呼ばないことを固定する。
// 2 つの assertion はそれぞれ別の実装箇所に対応する:
// - タスク切り替え → `clearSelection` (useDictionarySelection.ts の clearSelection)
// - アンマウント → イベント購読 effect の cleanup
// readSelection は両コンシューマで参照安定なので、タスク切り替えでは cleanup は走らない。
it('keeps the browser selection intact when the lookup closes on task change and feed unmount', () => {
popoverUnmounted.mockClear(); const removeAllRanges = vi.fn();
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment]} jobStream={stream} dictionaryEnabled />);
const body = screen.getByText('completed message body').closest('[data-dictionary-eligible]')!; const text = body.firstChild?.firstChild ?? body.firstChild!;
vi.spyOn(window, 'getSelection').mockReturnValue({ rangeCount: 1, isCollapsed: false, toString: () => 'completed', getRangeAt: () => ({ commonAncestorContainer: text, startContainer: text, endContainer: text, startOffset: 0, endOffset: 9, getBoundingClientRect: () => rect }), removeAllRanges } as unknown as Selection);
act(() => document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })));
rendered.rerender(<ChatMessageFeed task={{ ...task, id: 43 }} comments={[comment]} jobStream={stream} dictionaryEnabled />);
expect(screen.queryByRole('dialog')).toBeNull(); expect(popoverUnmounted).toHaveBeenCalledTimes(1);
expect(removeAllRanges).not.toHaveBeenCalled();
rendered.unmount();
expect(removeAllRanges).not.toHaveBeenCalled();
});
it('repositions the popover selection rectangle on chat scroll', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { callback(0); return 0; });
let currentRect = rect;
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment]} jobStream={stream} dictionaryEnabled />);
const body = screen.getByText('completed message body').closest('[data-dictionary-eligible]')!;
const text = body.firstChild?.firstChild ?? body.firstChild!;
vi.spyOn(window, 'getSelection').mockReturnValue({
rangeCount: 1, isCollapsed: false, toString: () => 'completed',
getRangeAt: () => ({ commonAncestorContainer: text, startContainer: text, endContainer: text, startOffset: 0, endOffset: 9, getBoundingClientRect: () => currentRect }),
removeAllRanges: vi.fn(),
} as unknown as Selection);
act(() => document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })));
currentRect = { ...rect, top: 2, bottom: 22 } as DOMRect;
fireEvent.scroll(screen.getByTestId('chat-message-scroll'));
expect(screen.getByRole('dialog').getAttribute('data-top')).toBe('2');
currentRect = { ...rect, top: 4, bottom: 24 } as DOMRect;
fireEvent(window, new Event('resize'));
expect(screen.getByRole('dialog').getAttribute('data-top')).toBe('4');
rendered.unmount();
});
});
describe('dictionary scope and live identity (#1003)', () => {
beforeEach(async () => { await i18n.changeLanguage('ja'); });
function progress(id: number, data: object, second: number): LocalTaskComment {
return { ...comment, id, author: 'agent', kind: 'progress', body: JSON.stringify(data),
createdAt: '2026-07-27T00:00:' + String(second).padStart(2, '0') + 'Z' };
}
function select(element: HTMLElement, text: string) {
const range = document.createRange();
range.selectNodeContents(element);
Object.defineProperty(range, 'getBoundingClientRect', { value: () => rect });
vi.spyOn(window, 'getSelection').mockReturnValue({
rangeCount: 1, isCollapsed: false, toString: () => text,
getRangeAt: () => range, removeAllRanges: vi.fn(),
} as unknown as Selection);
act(() => document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })));
}
it.each([0, 3])('keeps the live comment ineligible after a completed group, even when sorted to second %s', second => {
const comments = [
progress(10, { type: 'thinking', text: '確定した思考' }, 1),
progress(20, { movement: 'plan', tools: {}, durationMs: 100 }, 2),
progress(30, { type: 'thinking', text: '更新中の思考' }, second),
];
// Disconnected fallback keeps the current thinking comment visible.
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={comments} jobStream={{ ...stream, connected: false }} dictionaryEnabled />);
expect(screen.getByText('更新中の思考').closest('[data-dictionary-eligible]')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /plan/ }));
expect(screen.getByText('確定した思考').closest('[data-dictionary-eligible]')).not.toBeNull();
rendered.rerender(<ChatMessageFeed task={{ ...task, latestJob: { ...task.latestJob!, status: 'succeeded' } }} comments={comments} jobStream={stream} dictionaryEnabled />);
expect(screen.getByText('更新中の思考').closest('[data-dictionary-eligible]')).not.toBeNull();
});
it('passes only the selected question as dictionary context, excluding other questions and controls', () => {
const question = '量子化を有効にしますか?';
const ask: LocalTaskComment = { ...comment, kind: 'ask', author: 'agent', body: JSON.stringify({
type: 'ask_questions', questions: [
{ header: '最適化', question, options: [{ label: '有効', description: '容量を減らす' }, { label: '無効' }] },
{ header: '精度', question: '精度の条件は?', options: [{ label: '標準' }] },
],
}) };
const { container } = renderWithProviders(<ChatMessageFeed task={task} comments={[ask]} jobStream={stream} dictionaryEnabled />);
const element = screen.getByText(question);
const scope = element.closest('[data-dictionary-eligible]');
expect(scope?.textContent?.trim()).toBe(question);
expect([...container.querySelectorAll('[data-dictionary-eligible]')].map(node => node.textContent?.trim())).toEqual([question, '精度の条件は?']);
expect(screen.getByText('最適化').closest('[data-dictionary-eligible]')).toBeNull();
for (const button of screen.getAllByRole('button')) expect(button.closest('[data-dictionary-eligible]')).toBeNull();
select(element, question);
expect(screen.getByRole('dialog').getAttribute('data-context')).toBe(question);
});
it.each([false, true])('opens the dictionary from a collapsed preview (explicit summary: %s)', explicitSummary => {
const text = explicitSummary ? '確定した要約' : '書き終わった思考';
const comments = [
progress(10, { type: 'thinking', text: '書き終わった思考' }, 1),
progress(20, { movement: 'plan', tools: {}, durationMs: 100, ...(explicitSummary ? { summary: text } : {}) }, 2),
];
renderWithProviders(<ChatMessageFeed task={task} comments={comments} jobStream={stream} dictionaryEnabled />);
const element = screen.getByText(text);
expect(element.closest('[data-dictionary-eligible]')?.textContent?.trim()).toBe(text);
expect(screen.getByRole('button', { name: /plan/ }).closest('[data-dictionary-eligible]')).toBeNull();
select(element, text);
expect(screen.getByRole('dialog').getAttribute('data-context')).toBe(text);
});
});
@@ -0,0 +1,117 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import type { LocalTask, LocalTaskComment } from '../../api';
import type { JobStreamState } from '../../hooks/useJobStream';
import { fetchReasoningCalls, type ReasoningCall } from '../../api/reasoning';
import { ChatMessageFeed } from './ChatMessageFeed';
vi.mock('../../api/reasoning', async original => ({
...await original<typeof import('../../api/reasoning')>(), fetchReasoningCalls: vi.fn(),
}));
const fetchCalls = vi.mocked(fetchReasoningCalls);
const call = (over: Partial<ReasoningCall> = {}): ReasoningCall => ({
jobId: 'job-1', attempt: 1, callSeq: 1, piece: 'chat', movement: 'execute',
movementVisit: 1, iteration: 1, anchorKind: 'tool_calls', anchorToolCallId: 'same-id',
text: '編集前に内容を確認する', charCount: 12, truncated: false,
ts: '2026-09-12T00:00:00Z', firstId: 1, lastId: 1, ...over,
});
const tool = (id = 10, jobId = 'job-1'): LocalTaskComment => ({
id, taskId: 1, author: 'agent', kind: 'progress', injectedAt: null, attachments: [],
body: JSON.stringify({ type: 'tool_call', callId: 'same-id', movement: 'execute',
name: 'Read', args: '{}', result: '結果', isError: false, durationMs: 10, cacheHit: false }),
meta: JSON.stringify({ type: 'tool_execution', jobId, attempt: 1, reasoningCallSeq: 1 }),
createdAt: '2026-09-12T00:00:01Z',
});
const task = (running = false) => ({
id: 1, title: 'Task', latestJob: { id: 'stale-client-job', status: running ? 'running' : 'succeeded' },
}) as LocalTask;
const stream = (reasoningText = '') => ({
promptProgress: null, streamingText: '', reasoningText, answerStarted: false, toolCallStream: {},
connected: true, delegateStreams: {}, llmState: null, lastToolEvent: null, policyEvents: [],
}) as unknown as JobStreamState;
const page = (calls: ReasoningCall[], latestJobId = 'job-1') => ({
calls, hasMore: false, nextBefore: null, latestJobId,
});
describe('会話中の思考の位置 (#1206 第 2b 段)', () => {
beforeEach(async () => {
fetchCalls.mockReset();
await i18n.changeLanguage('ja');
});
it('ツールの直前へ畳んで置き、同じ思考を下部へ重ねない', async () => {
fetchCalls.mockResolvedValue(page([call()]));
const { container } = renderWithProviders(
<ChatMessageFeed task={task()} comments={[tool()]} jobStream={stream()} />,
);
await waitFor(() => expect(container.querySelectorAll('[data-inline-reasoning]')).toHaveLength(1));
expect(screen.queryByText('編集前に内容を確認する')).not.toBeInTheDocument();
const thought = container.querySelector('[data-inline-reasoning]')!;
expect(thought.compareDocumentPosition(screen.getByText('Read')) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(thought.querySelector('button')!);
expect(screen.getAllByText('編集前に内容を確認する')).toHaveLength(1);
expect(container.querySelector('[data-reasoning-history]')).toBeNull();
expect(fetchCalls).toHaveBeenCalledTimes(1);
expect(fetchCalls.mock.calls[0]![1]).toMatchObject({ history: true });
});
it('古い思考と位置不明の思考は実行別に残し、古いページも読み足せる', async () => {
fetchCalls.mockResolvedValueOnce({
...page([call({ anchorKind: 'orphan', anchorToolCallId: null })]), hasMore: true, nextBefore: 1,
});
const { container } = renderWithProviders(
<ChatMessageFeed task={task()} comments={[{ ...tool(), meta: null }]} jobStream={stream()} />,
);
await waitFor(() => expect(container.querySelectorAll('[data-reasoning-history]')).toHaveLength(1));
expect(container.querySelector('details')?.open).toBe(false);
fetchCalls.mockResolvedValueOnce(page([call({
jobId: 'old-job', firstId: 0, lastId: 0, text: '以前の理由',
})]));
fireEvent.click(screen.getByRole('button', { name: '古い思考を読み込む' }));
await waitFor(() => expect(container.querySelectorAll('[data-reasoning-history]')).toHaveLength(2));
expect(screen.queryByRole('button', { name: '古い思考を読み込む' })).not.toBeInTheDocument();
expect(container.querySelectorAll('[data-inline-reasoning]')).toHaveLength(0);
});
it('記録がツールへ結び付いたら実行中の欄から移す', async () => {
fetchCalls.mockResolvedValue(page([call({ anchorKind: null, anchorToolCallId: null })]));
const view = renderWithProviders(
<ChatMessageFeed task={task(true)} comments={[]} jobStream={stream('考え中')} />,
);
await waitFor(() => expect(fetchCalls).toHaveBeenCalledTimes(1));
expect(view.container.querySelector('[data-inline-reasoning]')).toBeNull();
fetchCalls.mockResolvedValue(page([call()]));
view.rerender(<ChatMessageFeed task={task(true)} comments={[tool()]} jobStream={stream()} />);
await waitFor(() => expect(view.container.querySelectorAll('[data-inline-reasoning]')).toHaveLength(1));
fireEvent.click(view.container.querySelector('[data-inline-reasoning] button')!);
expect(screen.getAllByText('編集前に内容を確認する')).toHaveLength(1);
expect(view.container.querySelector('[data-reasoning-history]')).toBeNull();
});
it('クライアントのジョブ番号が古くても、以前の思考を実行中の思考として表示しない', async () => {
fetchCalls.mockResolvedValue(page([call({ anchorKind: 'text', anchorToolCallId: null })], 'job-new'));
const { container } = renderWithProviders(
<ChatMessageFeed task={task(true)} comments={[]} jobStream={stream()} />,
);
await waitFor(() => expect(container.querySelectorAll('[data-reasoning-history]')).toHaveLength(1));
expect(screen.queryByText('思考中…')).not.toBeInTheDocument();
});
it('完了したステップ内のツールにも思考を渡す', async () => {
fetchCalls.mockResolvedValue(page([call()]));
const completion: LocalTaskComment = { ...tool(11),
body: JSON.stringify({ movement: 'execute', tools: { Read: 1 }, durationMs: 10 }), meta: null,
};
const { container } = renderWithProviders(
<ChatMessageFeed task={task()} comments={[tool(), completion]} jobStream={stream()} />,
);
await waitFor(() => expect(fetchCalls).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByText('execute'));
await waitFor(() => expect(container.querySelectorAll('[data-inline-reasoning]')).toHaveLength(1));
expect(container.querySelector('[data-inline-reasoning]')!.closest('li')).not.toBeNull();
});
});
@@ -0,0 +1,166 @@
// @vitest-environment jsdom
/**
* 実行中に出る再試行チップの枚数(issue #1538 / レビュー P2-2)。
*
* 元の訴えは「チップが積み上がって会話が読めない」。ステップの中では 5 枚で
* 止めていたのに、実行中の会話では 1 枚ずつそのまま出していたので、長く詰まった
* ステップでは 10 枚 20 枚と積み上がったままだった。
*/
import '../../test/dom-setup';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import i18n from '../../i18n';
import type { LocalTask, LocalTaskComment, MovementHistoryEvent } from '../../api';
import type { RetryPlacement } from './movementHistory';
import { ChatMessageFeed } from './ChatMessageFeed';
import { MovementMap } from './MovementMap';
vi.mock('../../hooks/usePieces', () => ({
usePiece: () => ({ data: { piece: { movements: [{ name: 'execute', instruction: '' }] } } }),
}));
vi.mock('../../hooks/useActivePet', () => ({ useActivePet: () => ({ data: null }) }));
const task: LocalTask = {
id: 8,
title: 'UI work',
body: 'Improve UI',
pieceName: 'build',
profile: 'auto',
outputFormat: 'markdown',
askPolicy: 'low',
priority: 'medium',
state: 'running',
workspacePath: null,
createdAt: '',
updatedAt: '',
latestJob: { id: 'j8', status: 'running', currentMovement: 'execute' },
} as unknown as LocalTask;
const jobStream = { promptProgress: null, streamingText: '', toolCallStream: [], connected: false, delegateStreams: {}, llmState: null } as any;
/** 完了イベントがまだ来ていない run の再試行=会話のいまの位置に出る分。 */
const liveHistory = (count: number): MovementHistoryEvent[] => [
{ eventId: 's1', ts: '2026-09-10T00:00:00.120Z', seq: 1, line: 1, runId: 'run-1', kind: 'movement_start', movement: 'execute', iteration: 0, payload: {} },
...Array.from({ length: count }, (_, index) => ({
eventId: `r${index + 1}`,
ts: `2026-09-10T00:00:${String(index + 1).padStart(2, '0')}.450Z`,
seq: index + 2,
line: index + 2,
runId: 'run-1',
kind: 'llm_call_retry' as const,
movement: 'execute',
iteration: 0,
payload: { attempt: index + 1, maxAttempts: 10, errorClass: 'connection' },
})),
];
/**
* 会話の発言。createdAt は SQLite の datetime('now') 由来=**秒どまり**。
* 実行中のチップの ts は toISOString 由来=ミリ秒つき。形が違う 2 つを
* 実際に混ぜて並べるのは会話の並べ替えだけなので、ここで押さえる。
*/
const say = (id: number, body: string, createdAt: string): LocalTaskComment => ({
id, taskId: 8, kind: 'request', body, author: 'user', attachments: [], createdAt, injectedAt: null,
});
const before = (a: Element | null, b: Element | null): boolean => {
expect(a).not.toBeNull();
expect(b).not.toBeNull();
return !!(a!.compareDocumentPosition(b!) & Node.DOCUMENT_POSITION_FOLLOWING);
};
describe('ChatMessageFeed live retry chips', () => {
beforeEach(async () => {
await i18n.changeLanguage('ja');
});
it('shows at most five chips while the step is still running', async () => {
const user = userEvent.setup();
render(
<I18nextProvider i18n={i18n}>
<ChatMessageFeed task={task} comments={[]} jobStream={jobStream} historyEvents={liveHistory(7)} />
</I18nextProvider>,
);
expect(document.querySelectorAll('[id^="trace-event-"]')).toHaveLength(5);
expect(document.getElementById('trace-event-r6')).toBeNull();
const more = screen.getByTestId('live-retry-show-more');
expect(more).toHaveTextContent('2');
await user.click(more);
expect(document.querySelectorAll('[id^="trace-event-"]')).toHaveLength(7);
expect(screen.queryByTestId('live-retry-show-more')).toBeNull();
});
it('leaves five or fewer chips alone', () => {
render(
<I18nextProvider i18n={i18n}>
<ChatMessageFeed task={task} comments={[]} jobStream={jobStream} historyEvents={liveHistory(5)} />
</I18nextProvider>,
);
expect(document.querySelectorAll('[id^="trace-event-"]')).toHaveLength(5);
expect(screen.queryByTestId('live-retry-show-more')).toBeNull();
});
it('honors the placement it is handed instead of counting again', () => {
// 割り当ては ChatPane が 1 回だけ数えて配る。ここで数え直すと、レールと
// 会話で入力が食い違う余地ができる(レビュー P2-4)。
const historyEvents = liveHistory(1);
const placement: RetryPlacement = {
byCompletionId: new Map(),
standalone: [],
live: [],
unassigned: [historyEvents[1]!],
};
render(
<I18nextProvider i18n={i18n}>
<ChatMessageFeed task={task} comments={[]} jobStream={jobStream} historyEvents={historyEvents} retryPlacement={placement} />
</I18nextProvider>,
);
// 自分で数えると r1 は「実行中」に入りチップが出る。渡された割り当てでは診断行。
expect(document.getElementById('trace-event-r1')).toBeNull();
expect(screen.getByTestId('retry-unassigned')).toBeInTheDocument();
});
it('keeps a live chip after the same-second comment it followed', () => {
// #1538 の訴えそのもの。文字として並べると '.' が 'Z' より小さいので、
// あとに起きたチップが同じ秒の発言より前へ回り込む。
const historyEvents: MovementHistoryEvent[] = [
{ eventId: 's1', ts: '2026-09-10T00:00:00.120Z', seq: 1, line: 1, runId: 'run-1', kind: 'movement_start', movement: 'execute', iteration: 0, payload: {} },
{ eventId: 'r1', ts: '2026-09-10T00:00:01.500Z', seq: 2, line: 2, runId: 'run-1', kind: 'llm_call_retry', movement: 'execute', iteration: 0, payload: { attempt: 1, maxAttempts: 10, errorClass: 'connection' } },
];
render(
<I18nextProvider i18n={i18n}>
<ChatMessageFeed
task={task}
comments={[say(1, 'さきの発言', '2026-09-10T00:00:01Z'), say(2, 'あとの発言', '2026-09-10T00:00:02Z')]}
jobStream={jobStream}
historyEvents={historyEvents}
/>
</I18nextProvider>,
);
const chip = document.getElementById('trace-event-r1');
expect(before(screen.getByText('さきの発言'), chip)).toBe(true);
expect(before(chip, screen.getByText('あとの発言'))).toBe(true);
});
it('lets the rail reach a chip the cap folded away', async () => {
const scrollSpy = vi.fn();
(Element.prototype as any).scrollIntoView = scrollSpy;
const historyEvents = liveHistory(7);
render(
<I18nextProvider i18n={i18n}>
<ChatMessageFeed task={task} comments={[]} jobStream={jobStream} historyEvents={historyEvents} />
<MovementMap task={task} comments={[]} historyEvents={historyEvents} />
</I18nextProvider>,
);
expect(document.getElementById('trace-event-r7')).toBeNull();
const dots = screen.getAllByRole('button', { name: /llm retry/i });
fireEvent.click(dots[6]!);
await waitFor(() => expect(document.getElementById('trace-event-r7')).not.toBeNull());
await waitFor(() => expect(scrollSpy).toHaveBeenCalled());
delete (Element.prototype as any).scrollIntoView;
});
});

Some files were not shown because too many files have changed in this diff Show More