sync: update from private repo (f6d625db)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-26 03:35:45 +00:00
parent 29ccaf1e92
commit b857c33ef6
371 changed files with 31312 additions and 8172 deletions
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App Harness</title>
<style>
* { box-sizing: border-box; }
html, body, #root { margin: 0; padding: 0; height: 100%; width: 100%; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/harness/main.tsx"></script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
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 ──────────────────────────────────────
//
// REQUIRES `npm run test:e2e:auth` + a running server (ui/playwright.auth.config.ts
// boots the real orchestrator with LOCAL AUTH on via ui/e2e-auth/config.e2e-auth.yaml).
// Do NOT expect this to run in the sandbox — there is no live server here.
//
// Admin user management is admin-only AND auth-only: the Users tab (page=users)
// renders only when `isAdmin && authEnabled` (App.tsx), and /api/admin/users is
// guarded by requireAdmin. So this can only be exercised under the local-auth
// harness, logged in as an admin. The bootstrap admin ([email protected]) is
// seeded by the server at startup; a regular member + a pending user are seeded
// in beforeAll into the SAME deterministic temp DB the webServer opens (the exact
// pattern used by sharing-scope.auth.spec.ts).
//
// Happy path: admin opens Users, the seeded users are listed, admin promotes the
// regular user to admin and approves the pending user — verified via the
// /api/admin/users API in the admin's authed browser context (the same dual
// UI+API proof sharing-scope uses). Negative/visibility: a regular member who
// logs in gets NO Users tab and is denied /api/admin/users (401/403).
const __dirname = dirname(fileURLToPath(import.meta.url));
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 DB_PATH = join(e2eTmp, 'e2e-auth.db');
// The bootstrap admin from config.e2e-auth.yaml.
const ADMIN = { email: '[email protected]', password: 'AdminPass123!' };
// Seeded personas (own *@adminmgmt.local namespace so they never collide with the
// other auth specs that share this DB).
const MEMBER = { email: '[email protected]', password: 'MemberPass123!', name: 'AdminMgmtMember' };
const PENDING = { email: '[email protected]', password: 'PendingPass123!', name: 'AdminMgmtPending' };
// A regular user that is NEVER promoted, reserved for the denial test (the
// promotion test mutates MEMBER → admin in the shared DB, so MEMBER cannot prove
// the non-admin denial afterwards).
const OUTSIDER = { email: '[email protected]', password: 'OutsiderPass123!', name: 'AdminMgmtOutsider' };
let memberId = '';
let pendingId = '';
let outsiderId = '';
const BENIGN_PATHS = new Set([
'/api/users/me/orgs',
'/api/mcp/connections',
'/api/mcp/servers',
'/api/mcp/user-servers',
'/api/ssh/connections',
]);
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
async function login(page: Page, email: string, password: string) {
await page.goto('/auth/login');
await page.fill('input[name="email"]', email);
await page.fill('input[name="password"]', password);
await Promise.all([
page.waitForURL(/\/ui(\/|$)/, { timeout: 15_000 }),
page.click('button[type="submit"]'),
]);
expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login');
}
test.beforeAll(async () => {
// webServer is already up (schema + migrations + bootstrap admin exist). Seed a
// regular active member and a PENDING user into the SAME deterministic DB.
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (dbPath: string) => {
getUserByEmail: (email: string) => { id: string } | null;
createLocalUser: (p: {
email: string; password: string; name?: string; role: string; status: string;
}) => { id: string };
close?: () => void;
};
};
const repo = new Repository(DB_PATH);
try {
const ensure = (u: typeof MEMBER, role: string, status: string) => {
const existing = repo.getUserByEmail(u.email);
if (existing) return existing.id;
return repo.createLocalUser({
email: u.email, password: u.password, name: u.name, role, status,
}).id;
};
memberId = ensure(MEMBER, 'user', 'active');
pendingId = ensure(PENDING, 'user', 'pending');
outsiderId = ensure(OUTSIDER, 'user', 'active');
} finally {
repo.close?.();
}
});
test('seed sanity: the personas exist with the expected roles/status', async ({ page }) => {
expect(memberId, 'member seeded').toBeTruthy();
expect(pendingId, 'pending user seeded').toBeTruthy();
await login(page, ADMIN.email, ADMIN.password);
const res = await page.request.get('/api/admin/users');
expect(res.status(), 'admin lists users').toBe(200);
const users = (await res.json()) as Array<{ id: string; role: string; status: string }>;
const member = users.find((u) => u.id === memberId);
const pending = users.find((u) => u.id === pendingId);
expect(member?.role).toBe('user');
expect(pending?.status).toBe('pending');
});
// Happy path: admin opens the Users page, the seeded users render in the list,
// and admin role-promotes the member + approves the pending user. The mutations
// fire PATCH /api/admin/users/:id; we assert the resulting state via the API in
// the same authed context (UI list rendering + API state = the dual proof used
// across the auth suite).
test('admin can list users, promote a member to admin, and approve a pending user', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, ADMIN.email, ADMIN.password);
await page.goto('/ui?page=users');
// The Users tab is admin+auth gated; with an admin logged in it renders.
await expect(page.getByTestId('nav-users')).toBeVisible();
await expect(page).toHaveURL(/[?&]page=users(&|$)/);
// The seeded users appear in the list (the list renders by email/name text).
await expect(page.getByText(MEMBER.email, { exact: false })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(PENDING.email, { exact: false })).toBeVisible();
// Drive the mutations through the API in the admin's authed browser context.
// (The Users page controls are i18n-text driven without stable test-ids; the
// page.request path exercises the SAME endpoints the buttons call — PATCH
// /api/admin/users/:id — without coupling to translation strings.)
const promote = await page.request.patch(`/api/admin/users/${memberId}`, {
data: { role: 'admin' },
});
expect(promote.ok(), 'promote member to admin').toBeTruthy();
const approve = await page.request.patch(`/api/admin/users/${pendingId}`, {
data: { status: 'active' },
});
expect(approve.ok(), 'approve pending user').toBeTruthy();
// Verify the resulting state via the list endpoint.
const after = await page.request.get('/api/admin/users');
expect(after.status()).toBe(200);
const users = (await after.json()) as Array<{ id: string; role: string; status: string }>;
expect(users.find((u) => u.id === memberId)?.role).toBe('admin');
expect(users.find((u) => u.id === pendingId)?.status).toBe('active');
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 }) => {
const fatalErrors = trackFatalErrors(page);
// OUTSIDER is a plain 'user' that no test promotes, so the non-admin denial
// holds regardless of test order in the shared DB.
await login(page, OUTSIDER.email, OUTSIDER.password);
await page.goto('/ui');
// The Users tab is admin+auth gated; a regular user never sees it.
await expect(page.getByTestId('nav-users')).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());
expect(fatalErrors, `fatal errors (DOM navigation only):\n${fatalErrors.join('\n')}`).toEqual([]);
});
+1 -1
View File
@@ -114,7 +114,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
+14 -2
View File
@@ -119,8 +119,20 @@ async function openSharedSpace(page: Page) {
return detail;
}
/** Open the seeded chat in the already-open shared space; returns the conversation. */
/**
* Open the seeded chat in the already-open shared space; returns the conversation.
*
* The chat list splits by owner scope ('自分' / '他のメンバー'). The seeded chat is
* owned by `manager`, so a viewing MEMBER finds it under '他のメンバー' (the default
* '自分' scope hides it). The toggle only renders when others' chats exist
* (`hasOthersTasks`), so for the OWNER (manager) it is absent and the chat is already
* visible under '自分'. Click the others-scope tab only when it is present.
*/
async function openSeededChat(page: Page) {
const othersScope = page.getByTestId('space-chat-scope-others');
if (await othersScope.count()) {
await othersScope.click();
}
const chatRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${chatTaskId}"]`);
await expect(chatRow).toBeVisible({ timeout: 15_000 });
await chatRow.click();
@@ -140,7 +152,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
@@ -0,0 +1,156 @@
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 ─────────────────────────────────────
//
// Covers the user-observable parts of M0 (spec:
// docs/superpowers/specs/2026-06-24-workspace-consolidation-m0-file-input-consistency-design.md):
//
// 1. Creating a case workspace through the UI scaffolds files/input + files/output
// (scaffoldCaseSpace) — they show up as folders in the Files tab right away.
// 2. The new-chat dialog warns (data-testid="ephemeral-warning") when the user
// switches the workspace mode to 一時的 (ephemeral), and hides it for the
// default 永続 (persistent).
//
// "The agent actually sees pre-placed files" is asserted by the unit test
// (src/engine/agent-loop.test.ts → buildSystemPrompt existing workspace files),
// since asserting LLM behaviour end-to-end is non-deterministic.
//
// Runs with LOCAL AUTH on (ui/playwright.auth.config.ts → config.e2e-auth.yaml)
// against the real built server. Seeds one regular user via the built Repository
// into the same deterministic DB the webServer opens.
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 DB_PATH = join(e2eTmp, 'e2e-auth.db');
const USER = { email: '[email protected]', password: 'WsFile123!', name: 'WsFileUser' };
async function login(page: Page, email: string, password: string) {
await page.goto('/auth/login');
await page.fill('input[name="email"]', email);
await page.fill('input[name="password"]', password);
await Promise.all([
page.waitForURL(/\/ui(\/|$)/, { timeout: 15_000 }),
page.click('button[type="submit"]'),
]);
expect(page.url(), 'login should not bounce back to /auth/login').not.toContain('/auth/login');
}
/** Create a fresh case workspace via the UI (this is what triggers scaffoldCaseSpace). */
async function createAndOpenCaseSpace(page: Page, label: string): 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 = `${label}-${Date.now()}`;
await titleInput.fill(title);
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: title });
await expect(caseRow).toBeVisible();
await caseRow.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
test.beforeAll(async () => {
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (dbPath: string) => {
getUserByEmail: (email: string) => { id: string } | null;
createLocalUser: (p: { email: string; password: string; name?: string; role: string; status: string }) => { id: string };
close?: () => void;
};
};
const repo = new Repository(DB_PATH);
try {
if (!repo.getUserByEmail(USER.email)) {
repo.createLocalUser({ email: USER.email, password: USER.password, name: USER.name, role: 'user', status: 'active' });
}
} finally {
repo.close?.();
}
});
test('new case workspace shows input/ and output/ folders in the Files tab', async ({ page }) => {
await login(page, USER.email, USER.password);
await createAndOpenCaseSpace(page, 'm0-files');
await page.getByTestId('space-tab-files').click();
await expect(page.getByTestId('space-files')).toBeVisible();
const inputTile = page.locator('[data-testid="space-file-tile"][data-kind="directory"][data-name="input"]');
const outputTile = page.locator('[data-testid="space-file-tile"][data-kind="directory"][data-name="output"]');
await expect(inputTile).toBeVisible({ timeout: 15_000 });
await expect(outputTile).toBeVisible();
});
test('fresh workspace chat list shows next-action guidance, and +新規 opens the create dialog (step 3 entry)', async ({ page }) => {
await login(page, USER.email, USER.password);
await createAndOpenCaseSpace(page, 'm0-step3');
// The chat tab is the default. A brand-new workspace has no chats, so the
// empty state should guide the user to send a request and explain where files
// and outputs live (step 3 / step 4 next-action copy).
await expect(page.getByTestId('space-tab-chat')).toBeVisible();
await expect(page.getByText('「+ 新規」からエージェントに依頼を送れます')).toBeVisible();
await expect(page.getByText(/成果物は ファイルタブの output\/ に保存されます/)).toBeVisible();
// Step 3 entry: the +新規 button opens the create dialog (no LLM run needed —
// we only assert the entry point reaches the dialog).
await page.getByTestId('space-new-chat-btn').click();
await expect(page.getByTestId('ephemeral-warning')).toHaveCount(0); // dialog open, persistent default
await expect(page.getByRole('button', { name: /永続/ })).toBeVisible();
});
test('opening a fresh chat surfaces step 4 guidance: artifacts live in the output/ folder', async ({ page }) => {
await login(page, USER.email, USER.password);
await createAndOpenCaseSpace(page, 'm0-step4');
// Step 3 → create a chat through the dialog. No LLM runs in this env
// (e2e-noop worker), so the job never completes — we only verify the
// create → open → "where do artifacts go" path, which is step 4's next-action.
await page.getByTestId('space-new-chat-btn').click();
await page.getByTestId('create-task-body').fill('step4 の成果物の在りかを確認するためのチャット');
await page.getByTestId('create-task-submit').click();
// The created chat opens inline (handleSpaceCreate → onSelectSpaceTask).
await expect(page.getByTestId('space-conversation')).toBeVisible({ timeout: 15_000 });
// 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'.)
await page.getByTestId('space-chat-tab-files').click();
await page.getByRole('button', { name: 'output', exact: true }).click();
await expect(page.getByTestId('output-empty-hint')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('output-empty-hint')).toContainText('output/');
});
test('new-chat dialog warns when ephemeral mode is selected, not for persistent', async ({ page }) => {
await login(page, USER.email, USER.password);
await createAndOpenCaseSpace(page, 'm0-warn');
await page.getByTestId('space-new-chat-btn').click();
// Default is persistent → no warning.
await expect(page.getByTestId('ephemeral-warning')).toHaveCount(0);
// Switch to 一時的 → warning appears.
await page.getByRole('button', { name: '一時的' }).click();
await expect(page.getByTestId('ephemeral-warning')).toBeVisible();
// Switch back to 永続 → warning gone.
await page.getByRole('button', { name: /永続/ }).click();
await expect(page.getByTestId('ephemeral-warning')).toHaveCount(0);
});
+122
View File
@@ -0,0 +1,122 @@
import { test, expect } from '@playwright/test';
import { createRequire } from 'node:module';
import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
// ── Public app-share view E2E (login-free, read-only) ─────────────────────────
//
// 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.
//
// A workspace app (apps/{name}/index.html) inside a space can be shared via a
// login-free, read-only public URL: /ui/app/:token. The public viewer
// (SharedAppView → AppRunner) resolves the token through /api/app-share/:token
// (no auth), renders the entry HTML in a sandboxed iframe, and exposes a
// read-only gateway (no write/delete). The published-tested logic libs
// (appShareUrl / sharedTabs / sharedView) are unit-covered; this proves the
// end-to-end public viewer flow that has no e2e.
//
// 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
// (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
// browser opens the public URL with NO session.
const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
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 WORKTREE_DIR = join(e2eTmp, 'workspaces');
const APP_NAME = 'e2e-public-app';
// A self-contained entry page (no relative assets) so the iframe render is
// deterministic without extra file fetches.
const APP_MARKER = 'E2E PUBLIC APP CONTENT';
const APP_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>${APP_NAME}</title></head>` +
`<body><h1>${APP_MARKER}</h1></body></html>`;
let spaceId = '';
let token = '';
test.beforeAll(async () => {
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (dbPath: string) => {
createSpace: (p: { kind: string; title: string; ownerId: string; visibility?: string }) => Promise<{ id: string }>;
createAppShareLink: (spaceId: string, appName: string, createdBy: string | null) => { token: string };
close?: () => void;
};
};
const repo = new Repository(dbPath);
try {
// Under no-auth the synthetic owner is 'local'.
const space = await repo.createSpace({ kind: 'case', title: '案件-公開アプリ共有', ownerId: 'local', visibility: 'private' });
spaceId = space.id;
// Entry HTML at the location findEntryPath() prefers:
// {worktree}/space/{id}/files/apps/{app}/index.html
const appDir = join(WORKTREE_DIR, 'space', spaceId, 'files', 'apps', APP_NAME);
mkdirSync(appDir, { recursive: true });
writeFileSync(join(appDir, 'index.html'), APP_HTML, 'utf-8');
// Mint the public share token (createdBy null under no-auth).
token = repo.createAppShareLink(spaceId, APP_NAME, null).token;
} finally {
repo.close?.();
}
});
// Happy path: opening the public URL with NO login resolves the token, renders
// the AppRunner full-screen, marks it read-only, and the seeded entry HTML loads
// into the sandboxed iframe.
test('public app-share URL renders the read-only viewer without login', async ({ page }) => {
expect(token, 'share token seeded').toBeTruthy();
// No session is established — this is the login-free public route.
await page.goto(`/ui/app/${token}`);
// The AppRunner shell renders (the public viewer mounts it full-screen).
const runner = page.getByTestId('app-runner');
await expect(runner).toBeVisible({ timeout: 15_000 });
// The read-only badge is shown (writable=false → the public-share copy). The
// text is a hardcoded literal, not i18n, so it is locale-independent.
await expect(runner).toContainText('公開共有(read-only');
// The entry HTML is loaded into the sandboxed iframe (srcDoc), so the frame
// element is present.
await expect(page.getByTestId('app-runner-frame')).toBeVisible();
const frame = page.frameLocator('[data-testid="app-runner-frame"]');
await expect(frame.getByText(APP_MARKER)).toBeVisible({ timeout: 15_000 });
// The public meta API is reachable WITHOUT auth and resolves the app.
const meta = await page.request.get(`/api/app-share/${token}`);
expect(meta.status(), 'public meta resolves').toBe(200);
const body = (await meta.json()) as { app?: { appName?: string; entryPath?: string | null } };
expect(body.app?.appName).toBe(APP_NAME);
expect(body.app?.entryPath).toContain(`apps/${APP_NAME}/`);
});
// Negative/visibility: an invalid/unknown token yields the public 404 screen
// (and the public meta API 404s) — never the real app.
test('an invalid app-share token shows the public not-found screen', async ({ page }) => {
await page.goto('/ui/app/this-token-does-not-exist');
// The 404 tone screen renders; the AppRunner must NOT mount.
await expect(page.getByText('アプリが見つかりません')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('app-runner')).toHaveCount(0);
// The public meta API rejects the unknown token.
const meta = await page.request.get('/api/app-share/this-token-does-not-exist');
expect(meta.status()).toBe(404);
});
+147
View File
@@ -0,0 +1,147 @@
import { test, expect, type Page } from '@playwright/test';
// ── Pieces editor (custom piece CRUD via the UI) 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 Pieces page (page=pieces) is NOT auth-gated (NAV_ITEMS: pieces
// requiresAuth:false). The published-tested `splitPieces` lib covers the
// custom/default split; this proves the actual editor wiring that has no e2e:
// 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.
// Backend state is verified through GET /api/pieces?source=custom — the same
// endpoint the page reads.
// With auth disabled the /api/auth/me probe 404s on purpose; the pieces page
// reads /api/pieces (200). Personal-space probes may 404 on an empty tree.
const BENIGN_PATHS = new Set([
'/api/auth/me',
'/api/users/me/orgs',
'/api/mcp/connections',
'/api/mcp/servers',
'/api/mcp/user-servers',
'/api/ssh/connections',
]);
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
// Unique name per run so reruns against the same throwaway DB never collide.
const pieceName = `e2e-custom-${Date.now()}`;
test('create a custom piece from the sidebar, then edit + save it via the editor', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=pieces');
await expect(page).toHaveURL(/[?&]page=pieces(&|$)/);
// The "Custom Pieces" 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 });
const createToggle = page.locator('button[title]').filter({ hasText: '+' }).first();
await createToggle.click();
// The inline name input appears (placeholder="piece-name"); type the name and
// submit with Enter → POST /api/pieces, then the new piece is selected.
const nameInput = page.getByPlaceholder('piece-name');
await expect(nameInput).toBeVisible();
await nameInput.fill(pieceName);
await nameInput.press('Enter');
// The new custom piece appears in the sidebar list.
await expect(page.getByText(pieceName, { exact: false })).toBeVisible({ timeout: 15_000 });
// It was persisted as a custom piece (the endpoint the page reads returns
// { pieces: [...] } with custom:true on user pieces).
await expect
.poll(async () => {
const res = await page.request.get('/api/pieces');
if (!res.ok()) return [];
const data = (await res.json()) as { pieces?: Array<{ name: string; custom?: boolean }> };
return (data.pieces ?? []).filter((p) => p.custom).map((p) => p.name);
}, { timeout: 15_000 })
.toContain(pieceName);
// Edit the piece through the YAML editor. Switch to YAML mode, set a new
// description, and Save (footer button is enabled only once dirty).
await page.getByRole('button', { name: 'YAML' }).click();
const yaml = page.locator('textarea').first();
await expect(yaml).toBeVisible({ timeout: 15_000 });
const edited = [
`name: ${pieceName}`,
'description: edited by e2e',
'max_movements: 25',
'initial_movement: execute',
'movements:',
' - name: execute',
' edit: true',
' persona: worker',
" instruction: 'do the thing'",
' allowed_tools: [Read, Write, Edit]',
' default_next: COMPLETE',
' rules:',
" - condition: '完了'",
' next: COMPLETE',
'',
].join('\n');
await yaml.fill(edited);
const saveBtn = page.getByRole('button', { name: 'Save' });
await expect(saveBtn).toBeEnabled({ timeout: 15_000 });
await saveBtn.click();
// The save persists to PUT /api/pieces/:name — verify the new description via
// the single-piece read endpoint (source=user-custom; response = { piece }).
await expect
.poll(async () => {
const res = await page.request.get(`/api/pieces/${encodeURIComponent(pieceName)}?source=user-custom`);
if (!res.ok()) return '';
const data = (await res.json()) as { piece?: { description?: string } };
return data.piece?.description ?? '';
}, { timeout: 15_000 })
.toContain('edited by e2e');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// Negative/visibility: built-in pieces are not deletable and the editor renders
// them read-only (no Save/Delete footer). 'chat' is a built-in piece always
// present. Selecting it shows the read-only badge instead of the editable footer.
test('a built-in piece opens read-only (no Save, no Delete)', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=pieces');
await expect(page.getByText('Default Pieces', { exact: false })).toBeVisible({ timeout: 15_000 });
// Open the built-in 'chat' piece from the Default Pieces section.
await page.getByRole('button', { name: /chat/i }).first().click();
// The editor marks it read-only (PieceEditor renders the readonly badge and
// omits the editable footer's Save button for built-ins).
await expect(page.getByText(/read-?only/i).first()).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole('button', { name: 'Save' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
+128
View File
@@ -0,0 +1,128 @@
import { test, expect, type Page } from '@playwright/test';
// ── Schedules (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.
//
// Selector grounding (read from SchedulesPage.tsx + i18n locales en/ja):
// - List: a "New schedule" / "新しいスケジュール" 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.
const BENIGN_PATHS = new Set([
'/api/auth/me',
'/api/users/me/orgs',
'/api/mcp/connections',
'/api/mcp/servers',
'/api/mcp/user-servers',
'/api/ssh/connections',
]);
const BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
if (!BENIGN_ASSET.test(pathname) && !BENIGN_PATHS.has(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
// 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 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 }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=schedules');
await expect(page).toHaveURL(/[?&]page=schedules(&|$)/);
// Open the editor.
await page.getByRole('button', { name: NEW_SCHEDULE }).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.
await page.getByPlaceholder(TITLE_PLACEHOLDER).fill(scheduleTitle);
await page.getByPlaceholder(PROMPT_PLACEHOLDER).fill('summarize today, e2e');
const submit = page.getByRole('button', { name: SUBMIT_CREATE });
await expect(submit).toBeEnabled({ timeout: 15_000 });
await submit.click();
// The new schedule appears in the list (each row is a button showing the title).
await expect(page.getByRole('button', { name: new RegExp(scheduleTitle) })).toBeVisible({ timeout: 15_000 });
// Backend: it was persisted via POST /api/scheduled-tasks (the same endpoint
// the list reads). A default daily schedule yields a cron expression.
let createdId = 0;
await expect
.poll(async () => {
const res = await page.request.get('/api/scheduled-tasks');
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 ?? []);
const match = list.find((t) => t.title === scheduleTitle);
if (match) createdId = match.id;
return list.map((t) => t.title);
}, { timeout: 15_000 })
.toContain(scheduleTitle);
expect(createdId, 'created schedule id resolved').toBeGreaterThan(0);
// Edit: select the schedule, open its editor and change the title. Driving the
// edit through the API exercises the SAME endpoint the editor's save calls
// (PATCH /api/scheduled-tasks/:id) without coupling to the i18n-only controls.
const editedTitle = `${scheduleTitle}-edited`;
const patched = await page.request.patch(`/api/scheduled-tasks/${createdId}`, {
data: { title: editedTitle },
});
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 expect(page.getByRole('button', { name: new RegExp(editedTitle) })).toBeVisible({ timeout: 15_000 });
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// Negative/visibility: opening the editor without a prompt body keeps the submit
// button disabled (the create gate). Proves the form validation wiring.
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();
// With only a title (no prompt body) the submit is disabled.
await page.getByPlaceholder(TITLE_PLACEHOLDER).fill('no-prompt-schedule');
const submit = page.getByRole('button', { name: SUBMIT_CREATE });
await expect(submit).toBeDisabled();
// Typing a prompt enables it.
await page.getByPlaceholder(PROMPT_PLACEHOLDER).fill('now there is a prompt');
await expect(submit).toBeEnabled();
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
+35 -35
View File
@@ -1,12 +1,11 @@
import { test, expect, type Page } from '@playwright/test';
// ── Tasks page sub-tabs (タスク / ファイル / 設定) ─────────────────────────────
// ── M2: Tasks tab removed, workspace one-true-home ────────────────────────────
//
// The Tasks page gained a sub-tab strip that surfaces the user's PERSONAL
// workspace files + settings without leaving the page. The default sub-tab
// (タスク) keeps the existing list+detail behavior; ファイル reuses the same
// <SpaceFiles> the Spaces page uses (testid `space-files`); 設定 reuses
// <SpaceSettings> (nav testid `space-settings-nav-agents`).
// The top-level "タスク" tab was removed; normal use is now entirely through
// Workspaces (spaces). On startup the personal workspace opens automatically so
// the user never lands on an empty rail. Legacy `?page=tasks` deep links are
// normalized onto the workspace model (Option A redirect) at load time.
//
// With auth disabled the /api/auth/me probe 404s on purpose and the UI falls
// back to the synthetic local user; the personal workspace is auto-created on
@@ -44,45 +43,46 @@ function trackFatalErrors(page: Page): string[] {
return fatalErrors;
}
test('tasks page: タスク / ファイル / 設定 sub-tabs switch the content area', async ({ page }) => {
test('nav: the Tasks tab no longer exists', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
// Tasks is the default page.
await page.goto('/ui');
// 1. The sub-tab strip and all three sub-tabs render.
const strip = page.getByTestId('tasks-subtabs');
await expect(strip).toBeVisible();
await expect(page.getByTestId('tasks-subtab-tasks')).toBeVisible();
await expect(page.getByTestId('tasks-subtab-files')).toBeVisible();
await expect(page.getByTestId('tasks-subtab-settings')).toBeVisible();
// 2. ファイル shows the personal-workspace file view (reused SpaceFiles).
await page.getByTestId('tasks-subtab-files').click();
await expect(page.getByTestId('space-files')).toBeVisible();
// URL state persists the active sub-tab for shareable/back-button behavior.
await expect(page).toHaveURL(/[?&]tasksTab=files(&|$)/);
// 3. 設定 shows the personal-workspace settings (reused SpaceSettings nav).
await page.getByTestId('tasks-subtab-settings').click();
await expect(page.getByTestId('space-settings-nav-agents')).toBeVisible();
await expect(page).toHaveURL(/[?&]tasksTab=settings(&|$)/);
// 4. タスク returns to the task list (default state, no tasksTab param).
await page.getByTestId('tasks-subtab-tasks').click();
await expect(page.getByTestId('space-files')).toHaveCount(0);
await expect(page.getByTestId('space-settings-nav-agents')).toHaveCount(0);
await expect(page).not.toHaveURL(/[?&]tasksTab=/);
// The removed top-level Tasks tab must not render in either layout.
await expect(page.getByTestId('nav-tasks')).toHaveCount(0);
// Workspaces is present (the new home).
await expect(page.getByTestId('nav-spaces')).toBeVisible();
// Removed sub-tab strip must be gone.
await expect(page.getByTestId('tasks-subtabs')).toHaveCount(0);
expect(fatalErrors).toEqual([]);
});
test('tasks page: tasksTab=files deep-link lands on the files sub-tab', async ({ page }) => {
test('startup opens the workspaces page (personal workspace auto-selected)', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?tasksTab=files');
await expect(page.getByTestId('tasks-subtabs')).toBeVisible();
await expect(page.getByTestId('space-files')).toBeVisible();
await page.goto('/ui');
// Spaces is the default page; the rail + a selected workspace detail render
// (the personal workspace is auto-opened so the rail is never empty).
await expect(page.getByTestId('space-detail')).toBeVisible();
// We must NOT be on the legacy tasks page.
expect(page.url()).not.toContain('page=tasks');
expect(fatalErrors).toEqual([]);
});
test('legacy ?page=tasks deep link redirects onto the workspace model', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui?page=tasks');
// The legacy page=tasks is normalized away (Option A) and the workspace
// detail surfaces instead.
await expect(page.getByTestId('space-detail')).toBeVisible();
await expect.poll(() => page.url()).not.toContain('page=tasks');
// The personal workspace is targeted via the space param.
await expect.poll(() => page.url()).toMatch(/[?&]space=/);
expect(fatalErrors).toEqual([]);
});
+97
View File
@@ -0,0 +1,97 @@
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';
// The tool-approval pause can only be produced naturally by a live LLM (the
// 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.
const __dirname = dirname(fileURLToPath(import.meta.url));
const dbPath = readFileSync(resolve(__dirname, '..', '.e2e-db-path'), 'utf-8').trim();
test('inline tool-approval: the card renders and Approve grants + resolves the request', async ({ page, request }) => {
// 1. Create a task (spawns a job).
const created = await request.post('/api/local/tasks', { data: { body: 'e2e tool request', piece: 'chat' } });
expect(created.ok()).toBeTruthy();
const taskId = (await created.json()).task.id as number;
// 2. Seed: park the spawned job for tool approval + a pending request.
const db = new Database(dbPath);
db.pragma('busy_timeout = 8000');
let jobId: string | undefined;
for (let i = 0; i < 60 && !jobId; i++) {
const row = db.prepare(`SELECT id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`)
.get(`local/task-${taskId}`) as { id: string } | undefined;
if (row) jobId = row.id;
else await new Promise((r) => setTimeout(r, 100));
}
if (!jobId) throw new Error('spawned job row never appeared');
db.prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(jobId);
const reqId = 'e2e-tool-req-1';
db.prepare(
`INSERT INTO tool_requests (id, task_id, job_id, piece_name, movement_name, tool_name, reason, category, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
).run(reqId, String(taskId), jobId, 'chat', 'respond', 'WebSearch', 'need to search the web', 'requested');
db.close();
// 3. Open the task; the inline approval card must render with tool + reason.
await page.goto(`/ui?task=${taskId}`);
const card = page.locator('[data-testid="tool-request-WebSearch"]:visible');
await expect(card).toBeVisible();
await expect(card).toContainText('WebSearch');
await expect(card).toContainText('need to search the web');
// While awaiting approval the normal composer is locked — sending a regular
// message here would spawn a duplicate parallel job on resume.
await expect(page.locator('textarea:visible').first()).toBeDisabled();
// 4. Approve → the card disappears (no longer pending).
await page.locator('[data-testid="tool-request-approve-WebSearch"]:visible').click();
await expect(page.getByTestId('tool-request-WebSearch')).toHaveCount(0);
// 5. Backend: request approved, tool granted to the task, job re-queued.
const verify = new Database(dbPath, { readonly: true });
const tr = verify.prepare(`SELECT status FROM tool_requests WHERE id = ?`).get(reqId) as { status: string };
const task = verify.prepare(`SELECT granted_tools FROM local_tasks WHERE id = ?`).get(taskId) as { granted_tools: string | null };
verify.close();
expect(tr.status).toBe('approved');
expect(task.granted_tools ?? '').toContain('WebSearch');
});
test('inline tool-approval: Deny resolves without granting', async ({ page, request }) => {
const created = await request.post('/api/local/tasks', { data: { body: 'e2e deny', piece: 'chat' } });
const taskId = (await created.json()).task.id as number;
const db = new Database(dbPath);
db.pragma('busy_timeout = 8000');
let jobId: string | undefined;
for (let i = 0; i < 60 && !jobId; i++) {
const row = db.prepare(`SELECT id FROM jobs WHERE repo = ? ORDER BY created_at DESC LIMIT 1`)
.get(`local/task-${taskId}`) as { id: string } | undefined;
if (row) jobId = row.id;
else await new Promise((r) => setTimeout(r, 100));
}
if (!jobId) throw new Error('spawned job row never appeared');
db.prepare(`UPDATE jobs SET status='waiting_human', wait_reason='tool_request' WHERE id = ?`).run(jobId);
const reqId = 'e2e-tool-req-2';
db.prepare(
`INSERT INTO tool_requests (id, task_id, job_id, piece_name, movement_name, tool_name, reason, category, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
).run(reqId, String(taskId), jobId, 'chat', 'respond', 'Bash', 'run a command', 'requested');
db.close();
await page.goto(`/ui?task=${taskId}`);
await expect(page.locator('[data-testid="tool-request-Bash"]:visible')).toBeVisible();
await page.locator('[data-testid="tool-request-deny-Bash"]:visible').click();
await expect(page.getByTestId('tool-request-Bash')).toHaveCount(0);
const verify = new Database(dbPath, { readonly: true });
const tr = verify.prepare(`SELECT status FROM tool_requests WHERE id = ?`).get(reqId) as { status: string };
const task = verify.prepare(`SELECT granted_tools FROM local_tasks WHERE id = ?`).get(taskId) as { granted_tools: string | null };
verify.close();
expect(tr.status).toBe('denied');
expect(task.granted_tools ?? '').not.toContain('Bash');
});
+282 -2
View File
@@ -1,10 +1,11 @@
{
"name": "agent-orchestrator-ui",
"name": "maestro-ui",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agent-orchestrator-ui",
"name": "maestro-ui",
"license": "Apache-2.0",
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.15",
@@ -30,6 +31,11 @@
"yaml": "^2.8.3"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/leaflet": "^1.9.21",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^6.0.2",
@@ -37,6 +43,13 @@
"vite": "^8.0.16"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
"integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -62,6 +75,31 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -1077,6 +1115,95 @@
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@@ -1088,6 +1215,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -1356,6 +1490,16 @@
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1453,6 +1597,29 @@
"node": ">=0.4.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1490,6 +1657,16 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -1684,6 +1861,13 @@
"layout-base": "^1.0.0"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2226,6 +2410,16 @@
"robust-predicates": "^3.0.2"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2254,6 +2448,13 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
@@ -2479,6 +2680,16 @@
"node": ">=0.10.0"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@@ -2893,6 +3104,16 @@
"loose-envify": "cli.js"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/marked": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
@@ -2968,6 +3189,16 @@
"node": ">=8.6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mlly": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz",
@@ -3309,6 +3540,21 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3381,6 +3627,13 @@
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -3471,6 +3724,20 @@
"node": ">=8.10.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -3612,6 +3879,19 @@
"integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==",
"license": "MIT"
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
+5
View File
@@ -33,6 +33,11 @@
"yaml": "^2.8.3"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/leaflet": "^1.9.21",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^6.0.2",
+24 -2
View File
@@ -2,7 +2,7 @@ import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { mkdtempSync } from 'node:fs';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
// __dirname under ESM.
@@ -17,9 +17,25 @@ const BASE_URL = `http://127.0.0.1:${PORT}`;
// Isolated, throwaway data + workspace dirs so the E2E never touches real data
// (real DB lives at ./data/maestro.db). A fresh temp DB also means the personal
// space is auto-created clean on first /api/local/spaces hit.
const e2eTmp = mkdtempSync(join(tmpdir(), 'maestro-e2e-'));
// 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
// 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.
if (!process.env.E2E_TMP) {
process.env.E2E_TMP = mkdtempSync(join(tmpdir(), 'maestro-e2e-'));
}
const e2eTmp = process.env.E2E_TMP;
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);
export default defineConfig({
testDir: resolve(__dirname, 'e2e'),
@@ -71,6 +87,12 @@ export default defineConfig({
// A fixed throwaway key keeps the run deterministic; the temp DB it
// encrypts is discarded after the run.
MCP_ENCRYPTION_KEY: '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff',
// Configure a STUB LLM so the first-run SetupWizard never gates the UI
// (it shows when no model is configured). The endpoint is never actually
// reached by these specs — they assert UI/DB state, not model output — so
// a fake host is fine and keeps the E2E self-contained (no caller env).
OLLAMA_BASE_URL: process.env.OLLAMA_BASE_URL ?? 'http://127.0.0.1:11434/v1',
OLLAMA_MODEL: process.env.OLLAMA_MODEL ?? 'e2e-stub-model',
},
},
});
+219 -554
View File
@@ -1,61 +1,40 @@
import { useState, useEffect, useRef, useMemo, useCallback, type ReactNode } from 'react';
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 { SetupWizard } from './components/setup/SetupWizard';
import { LocalTask, type Visibility } from './api';
import { createLocalTask, fetchLocalTask, type CreateLocalTaskInput, type Visibility } from './api';
import { useUrlState } from './hooks/useUrlState';
import { useToast } from './hooks/useToast';
import { useFileBrowser } from './hooks/useFileBrowser';
import { useFilePreview } from './hooks/useFilePreview';
import { useTaskOperations } from './hooks/useTaskOperations';
import { useLocalTaskList } from './hooks/useTaskList';
import { useLocalTask, useLocalTaskComments } from './hooks/useTaskDetail';
import { useSubtaskActivities } from './hooks/useSubtaskActivities';
import { useBranding } from './hooks/useBranding';
import { SwipeableTabs } from './components/mobile/SwipeableTabs';
import { useLocalStorageState } from './hooks/useLocalStorageState';
import { useTaskNotifications } from './hooks/useTaskNotifications';
import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications';
import { COLUMN_LIST, MOBILE_TAB_LIST, type MobileTabId, type PageId, type TasksTabId } from './lib/urlState';
import { normalizeLegacyTasksUrl, parseLegacyTasksUrl, buildUiUrlStateSearch, type PageId } from './lib/urlState';
import { confirmDiscardUnsaved } from './lib/unsavedGuard';
import { useBackdropClose } from './lib/useBackdropClose';
import { TopBar } from './components/layout/TopBar';
import { NavDrawer } from './components/layout/NavDrawer';
import { useEdgeSwipe } from './hooks/useEdgeSwipe';
import { visibleNavItemsFor } from './components/layout/TopBar';
import { useVisibleDetailTabs } from './components/detail/detailTabs';
import { ResizeHandle } from './components/layout/ResizeHandle';
import { TaskListPanel } from './components/list/TaskListPanel';
import { ChatPane } from './components/chat/ChatPane';
import { LocalDetailPanel } from './components/detail/DetailPanel';
import { CreateTaskDialog } from './components/create/CreateTaskDialog';
import { FilePreview } from './components/files/FilePreview';
import { OutputPreviewProvider } from './lib/output-preview-context';
import { stripOutputPrefix } from './lib/output-path-detect';
import { EmptyState } from './components/shared/EmptyState';
import { SkeletonChatPane } from './components/shared/Skeleton';
import { ChatPetOverlay } from './components/pets/ChatPetOverlay';
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 { 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 { SpacesPage } from './components/spaces/SpacesPage';
import { CrossSpaceCalendar } from './components/spaces/CrossSpaceCalendar';
import { SpaceFiles } from './components/spaces/SpaceDetail';
import { SpaceSettings } from './components/spaces/SpaceSettings';
import { useSpaces } from './hooks/useSpaces';
import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel';
import type { ConsoleStatus } from './lib/ssh-console-types';
import { CommandPalette } from './components/command/CommandPalette';
import { shouldOpenForKeyEvent, type CommandContext } from './lib/command-palette';
import { setThemePref } from './lib/theme';
import { shouldAutoFocus } from './lib/live-workspace';
export interface AuthUser {
id: string;
@@ -95,17 +74,16 @@ export function useAuthState(): AuthMode {
}
export function App() {
// 共有ページ: /ui/shared/:token — 認証不要
const sharedMatch = window.location.pathname.match(/^\/ui\/shared\/([^/]+)/);
if (sharedMatch) {
return <SharedView token={sharedMatch[1]} />;
}
// 招待リンク: /ui/invite/:token — 認証は必要だが、未ログイン時は JoinSpace 自身が
// ログイン導線(returnTo 付き)を出すので、ここでは認証ゲートを通さず直接表示する。
const inviteMatch = window.location.pathname.match(/^\/ui\/invite\/([^/]+)/);
if (inviteMatch) {
return <JoinSpace token={decodeURIComponent(inviteMatch[1])} />;
// 認証ゲートの外側で扱う公開ルートをパスマッチ(純関数)で判定する。
const publicRoute = matchPublicRoute(window.location.pathname);
if (publicRoute) {
// 共有タスク: /ui/shared/:token — 認証不要
if (publicRoute.kind === 'shared') return <SharedView token={publicRoute.token} />;
// 公開アプリ: /ui/app/:token — 認証不要・read-only
if (publicRoute.kind === 'app') return <SharedAppView token={publicRoute.token} />;
// 招待リンク: /ui/invite/:token — 認証は必要だが、未ログイン時は JoinSpace 自身が
// ログイン導線(returnTo 付き)を出すので、ここでは認証ゲートを通さず直接表示する。
if (publicRoute.kind === 'invite') return <JoinSpace token={publicRoute.token} />;
}
return <AuthenticatedApp />;
@@ -183,77 +161,109 @@ 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 { t: tList } = useTranslation('list');
const queryClient = useQueryClient();
// Apply branding (document.title + --brand-primary CSS var)
const branding = useBranding();
const { urlState, setUrlState, pushUrlState } = useUrlState();
const { status, search, sort, detailTab, mobileTab, taskId: localTaskId } = urlState;
const dashboardWidget = urlState.dashboardWidget ?? 'worker-status';
const setDashboardWidget = (slug: string) =>
setUrlState(prev => ({ ...prev, dashboardWidget: slug }));
// 認証なしで users ページにアクセスした場合は tasks にフォールバック
const page = (urlState.page === 'users' && !authEnabled) ? 'tasks' : urlState.page;
const { urlState, setUrlState, pushUrlState, replaceUrlSearch } = useUrlState();
// Latest urlState for reads inside async callbacks (closures capture a stale
// value otherwise). Updated every render; refs are safe to write during render.
const urlStateRef = useRef(urlState);
urlStateRef.current = urlState;
// 認証なしで users ページにアクセスした場合は spaces にフォールバック
const page = (urlState.page === 'users' && !authEnabled) ? 'spaces' : urlState.page;
// Tasks ページのサブタブ(タスク / ファイル / 設定)。ファイル・設定は個人
// ワークスペースを Spaces ページと同じコンポーネントで再利用する。
const tasksTab = urlState.tasksTab ?? 'tasks';
const setTasksTab = (next: TasksTabId) =>
setUrlState(prev => ({ ...prev, tasksTab: next }));
// 個人ワークスペースの解決。spaces 一覧から「自分が所有する kind === 'personal'」
// を拾う。バックエンドは既に個人スペースを所有者のみ可視に絞るが、二重の防御と
// して owner 一致も明示チェックする(admin が他ユーザーの個人スペースを誤って
// 自分のものとして拾わないように)。認証なしモードでは synthetic user が唯一の
// 個人スペースを所有するので user=null でも従来通り解決する。
// 一覧が空/ローディング中なら personalSpaceId は nullgraceful skeleton)。
const { data: spaces, isLoading: spacesLoading } = useSpaces();
const { data: spaces } = useSpaces();
const personalSpaceId =
spaces?.find(
s => s.kind === 'personal' && (user == null || s.ownerId === user.id),
)?.id ?? null;
// UI state
const [detailWidth, setDetailWidth] = useLocalStorageState<'normal' | 'focused'>(
'ui.detailMode',
'normal',
);
// focused 時の Chat 列幅 (px)。null は default (30vw) を意味する。
const [focusedChatPx, setFocusedChatPx] = useLocalStorageState<number | null>(
'ui.focusedChatPx',
null,
);
// Live workspace: auto-enter focused layout while a browser/ssh tab is open.
// A manual toggle/exit wins for the current view via `override`, which is
// KEYED to the current (tab,task): when the key changes the stale override is
// ignored at derivation time (no effect), so re-entering a live tab
// re-auto-focuses immediately with no stale-frame flicker.
const [override, setOverride] = useState<{ key: string; value: boolean } | null>(null);
const overrideKey = `${detailTab}:${localTaskId ?? ''}`;
// Key-match at derivation time = the leaving frame is immediately correct
// (no stale flash). The effect then clears the stale override so RETURNING to
// the same (tab,task) later re-auto-focuses instead of re-applying it.
const activeOverride = override && override.key === overrideKey ? override.value : null;
const isFocused =
activeOverride ?? (shouldAutoFocus(detailTab, localTaskId != null) || detailWidth === 'focused');
// 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
// personal workspace to resolve so the rewrite lands on the right rail, then
// replaceState (no history entry) and let useUrlState re-read. Runs at most
// once per legacy URL: after the rewrite, page!=='tasks' so the guard exits.
//
// M3: a task deep-link must land on the workspace that actually OWNS the task,
// not the personal one. We resolve the owning spaceId via a single-task fetch
// (id→spaceId) and only then rewrite. Spaceless rows (spaceId === null) and
// fetch failures fall back to the personal workspace. A bare `?page=tasks`
// (no task) has no task to resolve and always targets personal.
const legacyTasksRedirectDone = useRef(false);
useEffect(() => {
setOverride((o) => (o && o.key === overrideKey ? o : null));
}, [overrideKey]);
const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
const tabletDetailBackdrop = useBackdropClose(() => setTabletDetailOpen(false));
if (legacyTasksRedirectDone.current) return;
if (typeof window === 'undefined') return;
// Wait until spaces resolve so the personal workspace id is known.
if (spaces === undefined) return;
const rawSearch = window.location.search.replace(/^\?/, '');
// parseLegacyTasksUrl decides what counts as legacy (explicit page=tasks OR
// a bare ?task=ID deep link) and returns null otherwise.
const parsed = parseLegacyTasksUrl(rawSearch);
if (parsed == null) return;
legacyTasksRedirectDone.current = true;
// Replace synchronously onto the personal workspace (the Tasks page always
// lived there) in a single history-clean step. No async gap, so there is no
// window where the URL stays legacy while a fetch is in flight.
replaceUrlSearch(normalizeLegacyTasksUrl(rawSearch, personalSpaceId)!);
// If the deep-linked task actually belongs to a different (case) workspace,
// correct the rail afterwards. This is a normal navigation that composes
// with user actions: we only apply it if the user is still on that chat, so
// a slow fetch can't yank them back from somewhere they navigated to.
if (parsed.legacyTaskId != null) {
void (async () => {
try {
const task = await fetchLocalTask(parsed.legacyTaskId!);
if (!task.spaceId || task.spaceId === personalSpaceId) return;
// Correct the workspace with another history-clean replace — but only
// if the URL still shows this chat (no page param = on spaces, same
// chat id). If the user navigated away during the fetch, leave them be.
const cur = new URLSearchParams(window.location.search);
if (cur.get('page') == null && cur.get('chat') === String(parsed.legacyTaskId)) {
cur.set('space', task.spaceId);
replaceUrlSearch(cur.toString());
}
} catch {
// Unknown/forbidden task: the personal-workspace landing already opened the chat.
}
})();
}
}, [spaces, personalSpaceId, replaceUrlSearch]);
// Auto-open the personal workspace on startup so the workspaces page never
// lands the user on an empty rail (the Tasks tab used to be the default
// landing). One-shot: a ref guards it so a later in-session deselect (mobile
// "back to list") is NOT immediately re-selected. We only act when no space /
// chat is targeted, the legacy redirect didn't already pick one, and the
// personal workspace has resolved. A fresh reload is a new "startup" and may
// re-open personal — that matches "起動時は個人ワークスペースが開きます".
const personalSpaceAutoOpened = useRef(false);
useEffect(() => {
if (personalSpaceAutoOpened.current) return;
if (page !== 'spaces') return;
if (urlState.spaceId || urlState.spaceTaskId) return;
if (!personalSpaceId) return;
personalSpaceAutoOpened.current = true;
setUrlState(prev => ({ ...prev, spaceId: personalSpaceId }));
}, [page, urlState.spaceId, urlState.spaceTaskId, personalSpaceId, setUrlState]);
// UI state
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
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 visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
// Detail tabs (with live Browser/SSH gating) for the tablet chat header.
const visibleDetailTabs = useVisibleDetailTabs(localTaskId);
const openDetailTab = useCallback((id: string) => {
setUrlState(prev => ({ ...prev, detailTab: id as typeof detailTab }));
setTabletDetailOpen(true);
}, [setUrlState]);
const openNavDrawer = () => {
setTabletDetailOpen(false);
setNavDrawerOpen(true);
};
@@ -286,13 +296,37 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
*/
const [createInitialPiece, setCreateInitialPiece] = useState<string | null>(null);
const panelOpen = localTaskId !== null;
// Toast
const { toast, showToast } = useToast();
// URL sync
useEffect(() => { pushUrlState(urlState); }, [urlState, pushUrlState]);
// URL sync. While a legacy Tasks deep link is still pending normalization,
// skip the push: otherwise this would pushState the parsed fallback (page=tasks
// → spaces) and pollute history *before* the legacy redirect's replaceState
// runs, leaving the back button pointing at the broken pre-redirect URL.
useEffect(() => {
// Don't sync stale legacy state. `urlState.taskId` is the Tasks-page field;
// post-M2 nothing sets it in-session, so it is non-null only right after
// readUiUrlState parsed a legacy `?task=`/`?page=tasks` URL. While it is set
// we're mid-redirect: the legacy effect replaceState's the URL onto
// space/chat, but within that same flush urlState is still the pre-replace
// value. Pushing it would re-serialize `task=...` and undo the redirect (and
// pollute history). Suppressing on taskId covers the whole window — first
// render before spaces load, and the post-replace flush — and never misfires
// in-session because taskId is dead there.
// Hold the sync while a legacy redirect is pending so it never pushes the
// pre-redirect URL into history (and never undoes the redirect's replace):
// - taskId != null: the post-replace stale flush — the redirect replaced
// the URL but urlState (which still carries the dead Tasks-page taskId)
// hasn't caught up yet. taskId is never set in-session post-M2, so this
// only ever matches a just-parsed legacy URL.
// - parseLegacyTasksUrl on the live URL: the first-render window before the
// redirect runs, covering every legacy form including bare/invalid
// `?task=` and `?page=tasks` (same detector the redirect uses).
if (urlState.taskId != null) return;
if (typeof window !== 'undefined'
&& parseLegacyTasksUrl(window.location.search.replace(/^\?/, '')) != null) return;
pushUrlState(urlState);
}, [urlState, pushUrlState]);
// Data queries — split per concern so each tab fetches what it needs.
// Overview/Chat render as soon as task + comments arrive, without waiting
@@ -300,6 +334,73 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// its own when mounted.
const localTasksQuery = useLocalTaskList();
// Open a task by id inside the workspace (spaces) model. The Tasks tab was
// removed in M2, so every "open this task" entry point (calendar, OS
// notifications, ⌘K) resolves the task's owning workspace and lands the user
// there with the chat selected. Tasks with no space (legacy spaceless rows,
// M3) fall back to the personal workspace so they still surface somewhere.
//
// 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 seq = ++openTaskSeq.current;
// Select the chat immediately so the UI responds; only the WORKSPACE rail
// may need an async resolve below.
const select = (spaceId: string | undefined) =>
setUrlState(prev => ({
...prev,
page: 'spaces',
spaceId: spaceId ?? prev.spaceId,
spaceTaskId: id > 0 ? id : undefined,
}));
// spaceIdHint lets callers that already know the owning workspace (e.g. a
// just-created task, before the list query refetches) skip the lookup and
// avoid a wrong personal-workspace fallback.
if (spaceIdHint !== undefined) {
// null hint = spaceless row → personal fallback (no fetch needed).
select(spaceIdHint ?? personalSpaceId ?? undefined);
return;
}
// The list cache is the next-best source: a present row authoritatively
// gives spaceId (null → personal), so no fetch is needed.
const cached = localTasksQuery.data?.find(t => t.id === id);
if (cached) {
select(cached.spaceId ?? personalSpaceId ?? undefined);
return;
}
// Neither the hint nor the list cache knows the owning workspace (e.g. an OS
// notification / ⌘K / calendar open before the list query loads). Resolve
// the real spaceId via a single-task fetch so a non-personal task doesn't
// mis-land on the personal rail. We select the chat now (so the UI isn't
// blocked) and CORRECT the rail once the fetch resolves. Spaceless rows and
// failures fall back to personal.
select(personalSpaceId ?? undefined);
if (id <= 0) return;
void (async () => {
try {
const task = await fetchLocalTask(id);
if (openTaskSeq.current !== seq) return; // superseded by a newer open
const resolved = task.spaceId ?? personalSpaceId ?? undefined;
// Correct the rail with a REPLACE (no new history entry, so Back never
// lands on the bogus "personal + chat" intermediate) and only if the
// user is STILL on this chat. Judge that from current STATE, not the URL:
// the initial select() only updated React state and the URL sync runs
// later, so window.location can still be stale when the fetch resolves.
if (resolved !== undefined) {
const st = urlStateRef.current;
if (st.page === 'spaces' && st.spaceTaskId === id && st.spaceId !== resolved) {
replaceUrlSearch(buildUiUrlStateSearch({ ...st, spaceId: resolved || undefined }));
}
}
} catch {
// Personal fallback already applied above.
}
})();
}, [localTasksQuery.data, personalSpaceId, setUrlState, replaceUrlSearch]);
// ⌘K command palette
const [cmdkOpen, setCmdkOpen] = useState(false);
@@ -325,12 +426,12 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// unsaved-changes guard handleNavigatePage uses, so we don't silently
// discard an in-progress edit.
if (!confirmDiscardUnsaved()) return;
setUrlState((prev) => ({ ...prev, page: 'tasks', taskId: id }));
handleOpenTaskInSpace(id);
},
setTheme: setThemePref,
navItems: visibleNav.map((n) => ({ id: n.id, label: t(n.labelKey) })),
tasks: (localTasksQuery.data ?? []).map((t) => ({ id: t.id, title: t.title })),
}), [handleNavigatePage, setUrlState, visibleNav, localTasksQuery.data]);
}), [handleNavigatePage, handleOpenTaskInSpace, visibleNav, localTasksQuery.data]);
// ブラウザ通知設定 (localStorage) — 設定 UI は NotificationsForm が管理
const [notifyEnabled] = useLocalStorageState<boolean>('notify.enabled', true);
@@ -345,7 +446,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
enabled: notifyEnabled,
events: notifyEvents,
onNotificationClick: (taskId) => {
setUrlState(prev => ({ ...prev, page: 'tasks', taskId }));
handleOpenTaskInSpace(taskId);
},
});
@@ -358,190 +459,36 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
const data = e.data;
if (!data || typeof data !== 'object') return;
if (data.type === 'open-task' && typeof data.taskId === 'number') {
setUrlState(prev => ({ ...prev, page: 'tasks', taskId: data.taskId }));
handleOpenTaskInSpace(data.taskId);
}
};
navigator.serviceWorker.addEventListener('message', handler);
return () => navigator.serviceWorker.removeEventListener('message', handler);
}, [setUrlState]);
}, [handleOpenTaskInSpace]);
const localTaskQuery = useLocalTask(localTaskId, panelOpen);
const localCommentsQuery = useLocalTaskComments(localTaskId, panelOpen);
const localTasks = localTasksQuery.data ?? [];
const localTask = localTaskQuery.data ?? null;
const localComments = localCommentsQuery.data ?? [];
// Both queries must finish before mounting ChatPane — otherwise the task
// detail resolves first and ChatPane briefly renders with comments=[],
// which trips the "メッセージはまだありません" empty-state. data===undefined
// means not yet loaded; once loaded (even with zero comments) it's at
// worst [].
const chatReady = localTask !== null && localCommentsQuery.data !== undefined;
const hasSubtasks = (localTask?.subtasks?.length ?? 0) > 0;
const { data: subtaskActivities } = useSubtaskActivities(localTaskId, hasSubtasks);
// SSH console status (for conditional mobile SSH tab)
const { data: consoleStatus } = useQuery<ConsoleStatus>({
queryKey: ['console-status', localTaskId],
queryFn: async () => {
const r = await fetch(`/api/local/tasks/${localTaskId}/console/status`);
return r.ok ? r.json() : { active: false };
},
enabled: !!localTaskId,
refetchInterval: 5000,
});
const showSshMobileTab = consoleStatus?.active === true;
// File browser
const fileBrowser = useFileBrowser(localTaskId);
// File preview
const { previewState, previewLocalFile, previewSubtaskFile, closePreview } = useFilePreview(showToast);
// Task operations
const { handleCreateTask, handleComment, handleDelete, handleCancel } = useTaskOperations({
taskId: localTaskId,
showToast,
setUrlState,
setShowCreateDialog,
});
// Close tablet overlay when task changes
useEffect(() => { setTabletDetailOpen(false); }, [localTaskId]);
// Counts for TopBar
const localColumns = COLUMN_LIST.reduce((acc, s) => {
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
return acc;
}, {} as Record<string, LocalTask[]>);
// File preview handlers (bind taskId and section)
const handleLocalFilePreview = (filePath: string, name: string) => {
if (localTaskId) previewLocalFile(localTaskId, fileBrowser.section, filePath, name);
};
const handleSubtaskFilePreview = (taskId: number, jobId: string, category: string, filePath: string) => {
previewSubtaskFile(taskId, jobId, category, filePath);
};
// Output path link click handler for the OutputPreviewProvider that
// wraps the tasks page. Matches paths look like `output/sub/foo.md`
// — strip the `output/` prefix and pass the relative path to
// previewLocalFile with section pinned to 'output' (ignoring the
// current FileBrowser section, which may be 'logs' or 'input').
const handleOutputPathLinkClick = (matchedPath: string) => {
if (!localTaskId) return;
const relative = stripOutputPrefix(matchedPath);
const displayName = relative.includes('/') ? relative.substring(relative.lastIndexOf('/') + 1) : relative;
previewLocalFile(localTaskId, 'output', relative, displayName);
};
// TaskListPanel shared props
const taskListProps = {
localTasks,
selectedStatus: status,
sortMode: sort,
searchQuery: search,
activeTaskId: localTaskId,
// Owner scope (自分/他のユーザ). Only active when auth is on — in no-auth mode
// every task is owned by 'local' and the toggle would be meaningless.
scope: urlState.scope,
onScopeChange: (scope: 'mine' | 'others') => setUrlState(prev => ({ ...prev, scope })),
currentUserId: user?.id ?? null,
scopeEnabled: authEnabled && !!user,
onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })),
onSortChange: (s: string) => setUrlState(prev => ({ ...prev, sort: s as typeof sort })),
onSearchChange: (q: string) => setUrlState(prev => ({ ...prev, search: q })),
onSelectTask: (id: number) => setUrlState(prev => ({ ...prev, taskId: id, detailTab: 'overview' as const })),
onOpenCreate: () => setShowCreateDialog(true),
};
// タスクのファイルにアップロード/削除できるか(owner / admin / no-auth)。
// サーバ側でも checkTaskOwnership で再ゲートされるが、UI はツールバー表示に使う。
const canManageFiles =
!authEnabled || (!!user && (user.role === 'admin' || localTask?.ownerId === user.id));
const fileManagement = {
canManage: canManageFiles,
writableSection: fileBrowser.writableSection,
selected: fileBrowser.selected,
toggleSelect: fileBrowser.toggleSelect,
toggleSelectAll: fileBrowser.toggleSelectAll,
upload: fileBrowser.upload,
remove: fileBrowser.remove,
download: fileBrowser.download,
isUploading: fileBrowser.isUploading,
isDeleting: fileBrowser.isDeleting,
isDownloading: fileBrowser.isDownloading,
message: fileBrowser.message,
};
// Detail panel shared props
const detailPanelProps = (overrides?: { detailTab?: string; showWidthToggle?: boolean; onTabChange?: (t: string) => void; onClose?: () => void }) => ({
task: localTask,
taskId: localTaskId!,
section: fileBrowser.section,
currentPath: fileBrowser.currentPath,
entries: fileBrowser.entries,
pathSegments: fileBrowser.pathSegments,
loading: localTaskQuery.isLoading,
detailTab: overrides?.detailTab ?? detailTab,
detailWidth: (isFocused ? 'focused' : 'normal') as 'normal' | 'focused',
showWidthToggle: overrides?.showWidthToggle ?? true,
onTabChange: overrides?.onTabChange ?? (t => setUrlState(prev => ({ ...prev, detailTab: t }))),
onWidthToggle: () => {
const next = !isFocused;
setOverride({ key: overrideKey, value: next });
setDetailWidth(next ? 'focused' : 'normal');
},
onClose: overrides?.onClose ?? (() => setUrlState(prev => ({ ...prev, taskId: null, detailTab: 'overview' }))),
onDelete: handleDelete,
onSectionChange: fileBrowser.setSection,
onNavigate: fileBrowser.setCurrentPath,
onPreview: handleLocalFilePreview,
onRefresh: fileBrowser.refresh,
isRefreshing: fileBrowser.isRefreshing,
fileManagement,
onViewFullLog: () => handleLocalFilePreview('activity.log', 'activity.log'),
subtaskActivities,
onSubtaskFilePreview: handleSubtaskFilePreview,
shareToken: localTask?.shareToken ?? null,
});
// Layout calculation
const sidebarWidth = 'clamp(240px, 22vw, 280px)';
const detailPanelWidth = 'clamp(280px, 26vw, 440px)'; // normal mode 時のみ使用
const RAIL_PX = 48;
const HANDLE_PX = 4;
const MIN_CHAT_PX = 280;
const MIN_WS_PX = 280;
const RESERVED_RIGHT = RAIL_PX + HANDLE_PX + MIN_WS_PX; // = 332
// focused 用 grid: rail | chat (variable) | handle | workspace
const focusedGridCols = panelOpen
? `${RAIL_PX}px clamp(${MIN_CHAT_PX}px, var(--chat-w, 30vw), calc(100% - ${RESERVED_RIGHT}px)) ${HANDLE_PX}px minmax(${MIN_WS_PX}px, 1fr)`
: `${RAIL_PX}px minmax(0, 1fr)`;
// normal 用 grid (現状を維持)
const normalGridCols = panelOpen
? `${sidebarWidth} minmax(280px, 1fr) ${detailPanelWidth}`
: `${sidebarWidth} minmax(0, 1fr)`;
const gridStyle: React.CSSProperties = isFocused
? {
gridTemplateColumns: focusedGridCols,
['--chat-w' as string]: focusedChatPx !== null ? `${focusedChatPx}px` : '30vw',
}
: {
gridTemplateColumns: normalGridCols,
};
// Dynamic mobile tab list: always show Browser, conditionally show SSH
const mobileVisibleTabs: Array<{ id: MobileTabId; label: string }> = [
{ id: 'chat', label: 'Chat' },
{ id: 'overview', label: 'Overview' },
{ id: 'activity', label: 'Progress' },
{ id: 'files', label: 'Files' },
{ id: 'trace', label: 'Trace' },
{ id: 'browser', label: 'Browser' },
...(showSshMobileTab ? [{ id: 'ssh' as MobileTabId, label: 'SSH' }] : []),
];
const mobileVisibleTabIds = mobileVisibleTabs.map(t => t.id);
// Create a task from the global dialog (Help assistant "AI に聞く"). The
// Tasks tab was removed in M2, so the created task opens inside its
// workspace (personal by default). Space-internal "new chat" has its own
// flow in SpaceDetail; this path only serves the few non-space entry points.
const handleGlobalCreateTask = useCallback(async (
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');
}
}, [showToast, queryClient, handleOpenTaskInSpace, tList]);
return (
<div className="h-dvh flex flex-col overflow-hidden bg-slate-50 text-slate-900" {...edgeSwipe}>
@@ -573,8 +520,8 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div>
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
{page === 'spaces' && <div className="flex-1 min-h-0 overflow-hidden"><SpacesPage spaceId={urlState.spaceId} spaceTaskId={urlState.spaceTaskId} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined }))} onCreateTask={handleCreateTask} onOpenTask={(id) => setUrlState(prev => ({ ...prev, page: 'tasks', taskId: id, detailTab: 'overview' }))} /></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={(id) => setUrlState(prev => ({ ...prev, page: 'tasks', taskId: id, detailTab: 'overview' }))} /></div>}
{page === 'spaces' && <div className="flex-1 min-h-0 overflow-hidden"><SpacesPage spaceId={urlState.spaceId} spaceTaskId={urlState.spaceTaskId} 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>}
@@ -582,280 +529,14 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
{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 === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{/* サブタブ(タスク / ファイル / 設定)。ファイル・設定は個人ワークスペース。 */}
<div
data-testid="tasks-subtabs"
className="flex-shrink-0 flex items-center gap-1 border-b border-hairline px-3 pt-[env(safe-area-inset-top)]"
>
<button
type="button"
data-testid="tasks-subtab-tasks"
onClick={() => setTasksTab('tasks')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
tasksTab === 'tasks'
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
type="button"
data-testid="tasks-subtab-files"
onClick={() => setTasksTab('files')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
tasksTab === 'files'
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
</button>
<button
type="button"
data-testid="tasks-subtab-settings"
onClick={() => setTasksTab('settings')}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
tasksTab === 'settings'
? 'border-[var(--brand-primary)] text-slate-800'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
</button>
</div>
{/* ファイル / 設定: 個人ワークスペースを Spaces と同じコンポーネントで再利用 */}
{tasksTab !== 'tasks' && (
<div className="flex-1 min-h-0 overflow-hidden">
{personalSpaceId ? (
tasksTab === 'files' ? (
<div className="h-full overflow-y-auto p-4">
<SpaceFiles spaceId={personalSpaceId} />
</div>
) : (
<div className="h-full overflow-hidden p-2">
<SpaceSettings spaceId={personalSpaceId} showToast={showToast} />
</div>
)
) : (
<div className="h-full flex items-center justify-center text-sm text-slate-400">
{spacesLoading ? 'ワークスペースを読み込み中…' : '個人ワークスペースが見つかりません'}
</div>
)}
</div>
)}
{/* タスク: 既存のリスト + 詳細(挙動は従来どおり) */}
<div className={`flex-1 min-h-0 overflow-hidden ${tasksTab === 'tasks' ? '' : 'hidden'}`}>
{/* モバイル: 単一カラム (< sm = 640px) */}
<div className="block sm:hidden h-full">
{!panelOpen ? (
<div className="p-2 h-full">
<div className="bg-canvas border border-hairline rounded-md h-full overflow-hidden">
<TaskListWithSidePanel
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
activeWidgetSlug={dashboardWidget}
onActiveWidgetSlugChange={setDashboardWidget}
/>
</div>
</div>
) : (
<MobileDetailFlow>
<div className="flex-shrink-0 flex border-b border-hairline bg-canvas px-2 pt-[env(safe-area-inset-top)]">
{mobileVisibleTabs.map(({ id, label }) => (
<button
key={id}
onClick={() => setUrlState(prev => ({ ...prev, mobileTab: id }))}
className={`flex-1 py-3 text-xs border-b-2 active:bg-surface-2 active:scale-[0.97] transition-[transform,color,background-color,border-color] duration-100 ${
mobileTab === id
? 'text-slate-900 border-accent font-semibold'
: 'text-slate-500 border-transparent hover:text-slate-800 font-medium'
}`}
>
{label}
</button>
))}
<button
aria-label={t('shell.close')}
onClick={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))}
className="px-3 py-3 text-slate-400 hover:text-slate-800 active:scale-[0.92] active:text-slate-700 transition-[transform,color] duration-100"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<SwipeableTabs
tabs={mobileVisibleTabIds}
activeTab={mobileTab}
onTabChange={(id) => setUrlState(prev => ({ ...prev, mobileTab: id }))}
onSwipeBackFromFirst={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))}
renderTab={(id, preview) => (
// While dragging, browser (noVNC iframe) / ssh (WebSocket)
// peek as a light placeholder; the real panel mounts on commit.
preview && (id === 'browser' || id === 'ssh')
? <div className="h-full w-full flex items-center justify-center bg-canvas text-slate-400 text-sm font-medium">{id === 'browser' ? t('shell.browser') : 'SSH'}</div>
: id === 'chat'
? (chatReady
? <ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
: <SkeletonChatPane />)
: (localTaskId
? <LocalDetailPanel
{...detailPanelProps({
detailTab: id === 'overview' ? 'overview'
: id === 'activity' ? 'activity'
: id === 'trace' ? 'trace'
: id === 'browser' ? 'browser'
: id === 'ssh' ? 'ssh'
: 'files',
showWidthToggle: false,
onTabChange: t => setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })),
onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })),
})}
/>
: null)
)}
/>
</div>
{/* Mobile-only pet overlay. Anchored to the MobileDetailFlow
wrapper (which has `relative`) so the pet stays visible
across all tabs, not just Chat. Tablet+ uses the
ChatPane-internal instance instead. */}
{localTask && (
<ChatPetOverlay
taskId={localTask.id}
taskStatus={localTask.latestJob?.status ?? null}
currentActivity={localTask.latestJob?.currentActivity ?? null}
workerId={localTask.latestJob?.workerId ?? null}
lastBackendId={localTask.latestJob?.lastBackendId ?? null}
className="sm:hidden"
/>
)}
</MobileDetailFlow>
)}
</div>
{/* タブレット: 2カラム (sm 〜 xl)。3列デスクトップは横幅が足りないと詳細列が
狭まりタブが折り返すため、切替を xl(1280px) まで上げて中間帯はこの
2列+詳細オーバーレイで運用する。 */}
<div className="hidden sm:grid xl:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
<TaskListWithSidePanel
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
activeWidgetSlug={dashboardWidget}
onActiveWidgetSlugChange={setDashboardWidget}
/>
</div>
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
{chatReady ? (
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} detailTabs={visibleDetailTabs} activeDetailTab={detailTab} onSelectDetailTab={openDetailTab} />
) : panelOpen ? (
<SkeletonChatPane />
) : (
<EmptyState title={t('emptyState.title')} description={t('emptyState.desc')} onCreateTask={() => setShowCreateDialog(true)} />
)}
</div>
</div>
{/* デスクトップ: >= xl (1280px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
<div
className="hidden xl:grid gap-2 p-2 h-full"
data-focused-grid={isFocused ? '1' : undefined}
style={gridStyle}
>
{/* col 1: list or rail. wrapper が bg/border を保持。 */}
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
<TaskListWithSidePanel
upper={
isFocused
? <TaskListPanel
{...taskListProps}
mode="rail"
onExitFocused={() => { setOverride({ key: overrideKey, value: false }); setDetailWidth('normal'); }}
/>
: <div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} mode="list" /></div>
}
activeWidgetSlug={dashboardWidget}
onActiveWidgetSlugChange={setDashboardWidget}
defaultCollapsed={isFocused}
/>
</div>
{/* col 2: Chat */}
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
{chatReady ? (
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
) : panelOpen ? (
<SkeletonChatPane />
) : (
<EmptyState title={t('emptyState.title')} description={t('emptyState.descCenter')} onCreateTask={() => setShowCreateDialog(true)} />
)}
</div>
{/* col 3: Resize handle (focused + panelOpen 時のみ) */}
{isFocused && panelOpen && (
<ResizeHandle
onResize={(px) => {
const grid = document.querySelector<HTMLElement>('[data-focused-grid="1"]');
if (grid) grid.style.setProperty('--chat-w', `${px}px`);
}}
onResizeEnd={(px) => setFocusedChatPx(px)}
onReset={() => setFocusedChatPx(null)}
railPx={RAIL_PX}
minChatPx={MIN_CHAT_PX}
minWorkspacePx={MIN_WS_PX}
handlePx={HANDLE_PX}
/>
)}
{/* col 4: Workspace (detail) */}
{panelOpen && (
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
{localTaskId && <LocalDetailPanel {...detailPanelProps()} />}
</div>
)}
</div>
</div>
</div>
{/* Tablet: detail overlay */}
{tabletDetailOpen && panelOpen && (
<div className="hidden sm:block xl:hidden fixed inset-0 bg-black/40 z-40" {...tabletDetailBackdrop}>
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
{localTaskId && (
<LocalDetailPanel
{...detailPanelProps({
showWidthToggle: false,
onClose: () => setTabletDetailOpen(false),
})}
/>
)}
</div>
</div>
)}
</OutputPreviewProvider>}
{showCreateDialog && (
<CreateTaskDialog
onClose={() => { setShowCreateDialog(false); setCreateInitialPiece(null); }}
onSubmit={handleCreateTask}
onSubmit={handleGlobalCreateTask}
initialPiece={createInitialPiece ?? undefined}
/>
)}
{previewState && (
<FilePreview
name={previewState.name}
content={previewState.content}
imageSrc={previewState.imageSrc}
markdownImageBaseUrl={previewState.markdownImageBaseUrl}
onClose={closePreview}
taskId={previewState.taskId}
section={previewState.section}
filePath={previewState.filePath}
editable={previewState.editable}
trustedHtmlUrl={user || !authEnabled ? previewState.trustedHtmlUrl : undefined}
/>
)}
{branding.footerText && (
<footer className="flex-shrink-0 border-t border-slate-200 bg-canvas px-4 py-1.5 text-[10px] text-slate-500 text-center">
{branding.footerText}
@@ -876,19 +557,3 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
);
}
/**
* Mobile detail wrapper that adds horizontal swipe navigation between
* the Chat / Overview / Progress / Files / Trace tabs. Tap-to-switch
* via the tab bar still works (the swipe handler ignores touches that
* start on form controls / buttons / anchors).
*/
function MobileDetailFlow({ children }: { children: ReactNode }) {
// The horizontal tab swipe now lives in <SwipeableTabs> (the content area)
// so it can yield to native scroll inside wide content and follow the finger.
// This wrapper only provides `relative` for the app-level mobile pet overlay.
return (
<div className="relative flex flex-col h-full">
{children}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { getAppShareRawUrl } from './api';
// 公開アプリ共有の raw URL 組み立ては純関数なので node 環境でそのままテストできる。
describe('getAppShareRawUrl', () => {
it('builds the public app-share raw URL with an encoded path', () => {
expect(getAppShareRawUrl('tok', 'a/b c.png')).toBe(
'/api/app-share/tok/files/raw?path=a%2Fb+c.png',
);
});
it('encodes special characters in the token-scoped path', () => {
expect(getAppShareRawUrl('abc123', 'apps/x/index.html')).toBe(
'/api/app-share/abc123/files/raw?path=apps%2Fx%2Findex.html',
);
});
});
+300 -6
View File
@@ -177,6 +177,47 @@ export async function updateMissionBrief(
return data.missionBrief ?? null;
}
// ─── Tool-request mechanism ────────────────────────────────────────────────
export interface ToolRequest {
id: string;
taskId: string | null;
jobId: string | null;
spaceId: string | null;
pieceName: string;
movementName: string;
toolName: string;
reason: string | null;
category: 'requested' | 'blocked' | 'unknown';
status: 'pending' | 'approved' | 'denied' | 'auto_denied';
grantScope: 'task' | 'piece' | null;
decidedBy: string | null;
createdAt: string;
decidedAt: string | null;
}
export async function fetchToolRequests(taskId: number): Promise<ToolRequest[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/tool-requests`);
if (!res.ok) throw new Error('failed to fetch tool requests');
const data = await res.json();
return (data.toolRequests ?? []) as ToolRequest[];
}
export async function decideToolRequest(
taskId: number,
reqId: string,
decision: 'approve' | 'deny',
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/tool-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 CommentKind = 'request' | 'comment' | 'result' | 'ask' | 'progress' | 'handoff' | 'interjection';
export interface LocalTaskComment {
@@ -417,6 +458,47 @@ export async function fetchPickableUsers(): Promise<PickableUser[]> {
return (await res.json()) as PickableUser[];
}
// ─── ワークスペース ツールポリシー ────────────────────────────────────────
export interface ToolCategory {
name: string;
sensitive: boolean;
enabled: boolean;
}
export interface SpaceToolPolicy {
disabledSafe: string[];
enabledSensitive: string[];
}
export interface SpaceToolPolicyResponse {
policy: SpaceToolPolicy;
categories: ToolCategory[];
sensitiveTools: { name: string; enabled: boolean }[];
}
export async function fetchSpaceToolPolicy(spaceId: string): Promise<SpaceToolPolicyResponse> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch tool policy');
return data as SpaceToolPolicyResponse;
}
export async function updateSpaceToolPolicy(
spaceId: string,
patch: { disabledSafe?: string[]; enabledSensitive?: string[] },
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/tool-policy`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to update tool policy');
}
}
export async function createSpace(input: {
title: string;
description?: string;
@@ -589,6 +671,54 @@ export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'i
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
// ── Office プレビュー (Excel / PowerPoint) ───────────────────────────────
// サーバが Excel→シートのセル配列、PPTX→スライド画像(PNG data URL)に変換して返す。
export interface OfficeSpreadsheetSheet {
name: string;
rows: string[][];
rowCount: number;
colCount: number;
truncated: boolean;
}
export interface OfficeSpreadsheetPreview {
kind: 'spreadsheet';
sheets: OfficeSpreadsheetSheet[];
truncated: boolean;
}
export interface OfficePresentationPreview {
kind: 'presentation';
slides: { index: number; dataUrl: string }[];
slideCount: number;
truncated: boolean;
}
export type OfficePreview = OfficeSpreadsheetPreview | OfficePresentationPreview;
/** office-preview エンドポイントの失敗を、変換エンジン未導入(503)とそれ以外で区別できる型。 */
export class OfficePreviewError extends Error {
/** サーバが返した error コード ('converter_unavailable' 等)。 */
code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = 'OfficePreviewError';
this.code = code;
}
}
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);
}
return (await res.json()) as OfficePreview;
}
export function getLocalFileOfficePreviewUrl(taskId: number, section: 'workspace' | 'input' | 'output' | 'logs', path: string): string {
const params = new URLSearchParams({ section, path });
return `${BASE}/local/tasks/${taskId}/files/office-preview?${params.toString()}`;
}
// アップロード・削除が許される区分。サーバ側 WRITABLE_SECTIONS と一致させること。
export type WritableTaskSection = 'input' | 'output';
@@ -692,6 +822,11 @@ export function getSpaceTrustedHtmlUrl(spaceId: string, path: string): string {
return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`;
}
export function getSpaceFileOfficePreviewUrl(spaceId: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/local/spaces/${spaceId}/files/office-preview?${params.toString()}`;
}
export async function uploadSpaceFiles(
spaceId: string,
path: string,
@@ -724,6 +859,40 @@ export async function deleteSpaceFiles(
return data as { deleted: string[]; skipped: string[] };
}
// スペースのワークスペースに空フォルダを作る(owner/editor のみ)。path は親からの
// 相対パス。サーバ側で spaceFilesDir に封じ込め(traversal は 400)。
export async function createSpaceFolder(
spaceId: string,
path: string,
): Promise<{ created: string }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/mkdir`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create folder');
return data as { created: string };
}
// スペースのファイル/フォルダをリネーム・移動する(owner/editor のみ)。from→to は
// いずれも相対パス完全形。構造ディレクトリ(input/output/logs/apps/readonly)と隠しは
// 不可。衝突時はサーバが `{stem} (N){ext}` に自動リネームし、確定先 path を返す。
export async function moveSpaceFile(
spaceId: string,
from: string,
to: string,
): Promise<{ from: string; to: string }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from, to }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to move');
return data as { from: string; to: string };
}
// スペースの複数ファイルを zip でまとめてダウンロード(read)。サーバが paths を
// spaceFilesDir に封じ込め、ファイルのみ zip 化。1 件でも zip にする。
export async function downloadSpaceFilesZip(
@@ -771,7 +940,8 @@ export interface CalendarEvent {
ownerId: string | null;
date: string; // 開始日 YYYY-MM-DD(ローカル日付)
endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日
time: string | null; // HH:MM。null = 終日
time: string | null; // 開始 HH:MM。null = 終日
endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null
title: string;
description: string | null;
createdBy: 'user' | 'agent';
@@ -862,13 +1032,13 @@ export async function fetchSpaceCalendarDay(
export async function createCalendarEvent(
spaceId: string,
input: { date: string; endDate?: string | null; time?: string | null; title: string; description?: string | null },
input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; title: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, ...rest } = input;
const { endDate, endTime, ...rest } = input;
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 }),
body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create event');
@@ -878,11 +1048,12 @@ export async function createCalendarEvent(
export async function updateCalendarEvent(
spaceId: string,
eventId: number,
patch: { date?: string; endDate?: string | null; time?: string | null; title?: string; description?: string | null },
patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, ...rest } = patch;
const { endDate, endTime, ...rest } = patch;
const body: Record<string, unknown> = { ...rest };
if (endDate !== undefined) body.end_date = endDate;
if (endTime !== undefined) body.end_time = endTime;
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -1154,6 +1325,102 @@ export async function fetchSharedSubtaskActivities(token: string): Promise<Subta
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}/)をログイン不要の公開トークンで
// read-only 配信する。公開取得(/api/app-share/:token/*)は認証なし、発行/失効
// /api/local/spaces/:id/apps/:app/share)は canManageSpaceowner/admin)が必要。
// サーバ側で apps/{app}/output/ にパス封じ込め。詳細は src/bridge/app-share-api.ts。
// 公開アプリのメタ。entryPath は entry HTMLapps/{app}/index.html 等)。空アプリ等で
// 見つからなければ null になりうる(呼び出し側で 404 表示)。失効/不正トークンは throw。
export async function fetchAppShareMeta(token: string): Promise<{ appName: string; entryPath: string | null }> {
const res = await fetch(`${BASE}/app-share/${token}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Not found');
return data.app;
}
export async function fetchAppShareFileContent(token: string, path: string): Promise<string> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/app-share/${token}/files/content?${params.toString()}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to read file');
}
return res.text();
}
export async function fetchAppShareFiles(
token: string,
dir: string = '',
): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams();
if (dir) params.set('dir', dir);
const qs = params.toString();
const res = await fetch(`${BASE}/app-share/${token}/files/list${qs ? `?${qs}` : ''}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export function getAppShareRawUrl(token: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/app-share/${token}/files/raw?${params.toString()}`;
}
// 発行/失効/取得(canManageSpace)。shareUrl は相対パス(/ui/app/:token)。表示時は
// window.location.origin を前置する。
export async function createAppShareLink(
spaceId: string,
app: string,
): Promise<{ token: string; shareUrl: string }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`, {
method: 'POST',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to create share link');
return data as { token: string; shareUrl: string };
}
export async function getAppShareLink(
spaceId: string,
app: string,
): Promise<{ token: string | null; shareUrl?: string; revokedAt?: string | null }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch share link');
return data as { token: string | null; shareUrl?: string; revokedAt?: string | null };
}
export async function revokeAppShareLink(spaceId: string, app: string): Promise<{ ok: true }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/apps/${encodeURIComponent(app)}/share`, {
method: 'DELETE',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to revoke share link');
return data as { ok: true };
}
// --- Browser Session Profiles ---
export interface BrowserSessionProfile {
id: number;
@@ -1891,3 +2158,30 @@ export async function getUsageDaily(params: {
if (!res.ok) throw new Error(`Failed to load usage (${res.status})`);
return res.json();
}
// ── Delegate Observability (D0) ──────────────────────────────────────────
// Task 3 API: GET /api/local/tasks/:id/delegate-runs
// GET /api/local/tasks/:id/delegate-runs/:runId/timeline
/** Lightweight event record returned by the delegate-run timeline endpoint. */
export interface TraceEventLite {
eventId: string;
ts: string;
seq: number;
kind: string;
movement?: string;
iteration?: number;
payload: unknown;
}
export async function fetchDelegateRuns(taskId: number): Promise<import('./lib/delegateRuns').DelegateRun[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs`);
if (!res.ok) throw new Error(`fetchDelegateRuns failed: ${res.status}`);
return (await res.json()).runs;
}
export async function fetchDelegateRunTimeline(taskId: number, delegateRunId: string): Promise<TraceEventLite[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/delegate-runs/${encodeURIComponent(delegateRunId)}/timeline`);
if (!res.ok) throw new Error(`fetchDelegateRunTimeline failed: ${res.status}`);
return (await res.json()).events;
}
+11 -3
View File
@@ -18,6 +18,14 @@ interface ChatMessageProps {
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.
*/
hideAttachments?: boolean;
}
interface ProgressData {
@@ -361,7 +369,7 @@ function CommentAttachments({ attachments, taskId }: { attachments?: string[]; t
);
}
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) {
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hideAttachments }: ChatMessageProps) {
const { t } = useTranslation('chat');
const { kind, author, body, createdAt } = comment;
@@ -380,7 +388,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
{author} · {new Date(createdAt).toLocaleString()}
</div>
<MarkdownText text={body} />
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
{!hideAttachments && <CommentAttachments attachments={comment.attachments} taskId={taskId} />}
<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>
@@ -398,7 +406,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
{author} · {new Date(createdAt).toLocaleString()}
</div>
<MarkdownText text={body} />
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
{!hideAttachments && <CommentAttachments attachments={comment.attachments} taskId={taskId} />}
</div>
</div>
);
+41 -10
View File
@@ -4,9 +4,12 @@ import { LocalTask, LocalTaskComment } from '../../api';
import { ChatMessage } from './ChatMessage';
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
import { ContextUsageGauge } from '../detail/ContextUsageGauge';
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
import { SubtaskInlineCard } from './SubtaskInlineCard';
import RotatingTips from './RotatingTips';
import { ToolRequestApproval } from './ToolRequestApproval';
import { DelegateLiveConsole } from './DelegateLiveConsole';
import { useJobStream } from '../../hooks/useJobStream';
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
@@ -174,7 +177,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
};
const jobStatus = task.latestJob?.status;
const { promptProgress, streamingText, toolCallStream, connected } = useJobStream(task.id, jobStatus);
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams } = useJobStream(task.id, jobStatus);
// Most-recent content-field tool with decoded content to show live.
const liveToolContent = useMemo(() => {
@@ -199,6 +202,18 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
const hasActiveJob = isBusy || isPending;
// Tool-request mechanism: while the agent is paused waiting for the user to
// approve/deny a requested tool, lock the normal composer. Sending a regular
// message here would create a SECOND job (a waiting_human reply) that runs in
// parallel with the original once it's resumed by the approval → duplicate
// execution. Gate on the JOB's waitReason (available immediately from the
// task data) rather than the async tool-requests fetch, so there is no
// unlocked gap right after the task flips to waiting_human. A disabled
// textarea also blocks the Ctrl+Enter submit path.
const awaitingToolApproval =
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
const composerLocked = inputLocked || awaitingToolApproval;
// Release the submit lock once the agent is visibly responding: the new user
// comment is reflected in the list AND the job has been picked up by a worker
// (isBusy=true). This bridges the queued->dispatching gap where a stale
@@ -235,16 +250,15 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
return (
<div className="relative flex flex-col h-full overflow-hidden">
{/* Tablet+ only. Mobile renders its own app-level instance so the
pet is visible across all mobile tabs (Progress / Files / Trace /
Browser / SSH), not just Chat. */}
{/* Shown at every breakpoint. The overlay is absolutely positioned at the
bottom-right of the chat pane with pointer-events:none, so it never
blocks the mobile composer — keep it visible on narrow screens too. */}
<ChatPetOverlay
taskId={task.id}
taskStatus={task.latestJob?.status ?? null}
currentActivity={task.latestJob?.currentActivity ?? null}
workerId={task.latestJob?.workerId ?? null}
lastBackendId={task.latestJob?.lastBackendId ?? null}
className="hidden sm:block"
/>
{/* Header */}
<div className="flex-shrink-0 border-b border-hairline bg-canvas px-4 py-2.5">
@@ -354,7 +368,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span>Processing</span>
<span>{t('pane.processing')}</span>
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-1">
@@ -390,6 +404,12 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
</div>
)}
{isBusy && Object.keys(delegateStreams).length > 0 && (
<div className="flex justify-start mt-1.5">
<DelegateLiveConsole streams={delegateStreams} />
</div>
)}
{/* General rotating tips to make the wait useful (running only). */}
{isBusy && <RotatingTips />}
</div>
@@ -430,6 +450,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
</div>
)}
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
{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>
@@ -453,6 +474,16 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
))}
</div>
)}
{/* コンテキスト残量を入力欄の直上に常時表示。概要タブまでスクロールせずに、
入力しながら「あとどれくらい書けるか」を把握できる(issue #009)。 */}
<div className="mb-2">
<ContextUsageGauge
compact
promptTokens={task.latestJob?.contextPromptTokens}
limitTokens={task.latestJob?.contextLimitTokens}
jobStatus={task.latestJob?.status}
/>
</div>
<div className="flex gap-1.5 items-end">
<input
ref={fileInputRef}
@@ -463,7 +494,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={inputLocked || submitting}
disabled={composerLocked || submitting}
className="flex-shrink-0 w-9 h-9 flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-surface rounded-md transition-colors disabled:opacity-50 disabled:hover:bg-transparent disabled:cursor-not-allowed"
title={t('pane.attachFile')}
aria-label={t('pane.attachFile')}
@@ -478,8 +509,8 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
onKeyDown={handleKeyDown}
onPaste={e => void handlePaste(e)}
rows={2}
disabled={inputLocked}
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
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')}
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
/>
{isBusy && onCancel ? (
@@ -524,7 +555,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
</button>
) : (
<button
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
onClick={handleSubmit}
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
>
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import '../../test/dom-setup';
import '../../i18n';
import { describe, expect, it } from 'vitest';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
import { DelegateLiveConsole } from './DelegateLiveConsole';
import type { DelegateStreamEntry } from '../../hooks/useJobStream';
function entry(o: Partial<DelegateStreamEntry> = {}): DelegateStreamEntry {
return {
delegateRunId: 'r1', parentRunId: null, depth: 1, description: 'tweet 3 を深掘り',
status: 'running', text: '論点を整理しています', currentTool: null, ...o,
};
}
describe('DelegateLiveConsole', () => {
it('streams が空なら何も描画しない', () => {
const { container } = renderWithProviders(<DelegateLiveConsole streams={{}} />);
expect(container.firstChild).toBeNull();
});
it('実行中の delegate カードを description + ライブ文字つきで描画', () => {
renderWithProviders(<DelegateLiveConsole streams={{ r1: entry() }} />);
expect(screen.getByText('tweet 3 を深掘り')).toBeTruthy();
expect(screen.getByText(/論点を整理しています/)).toBeTruthy();
});
it('currentTool があるとツール実行中を表示', () => {
renderWithProviders(<DelegateLiveConsole streams={{ r1: entry({ text: '', currentTool: 'WebFetch' }) }} />);
expect(screen.getByText(/WebFetch/)).toBeTruthy();
});
it('完了した run は表示しない(履歴は概要に委ねる)— running だけ残す', () => {
const streams = {
done1: entry({ delegateRunId: 'done1', description: '完了したやつ', status: 'success' }),
run1: entry({ delegateRunId: 'run1', description: '走ってるやつ', status: 'running' }),
};
renderWithProviders(<DelegateLiveConsole streams={streams} />);
expect(screen.queryByText('完了したやつ')).toBeNull();
expect(screen.getByText('走ってるやつ')).toBeTruthy();
});
it('全 run が完了済みなら何も描画しない', () => {
const streams = {
done1: entry({ delegateRunId: 'done1', status: 'success' }),
done2: entry({ delegateRunId: 'done2', status: 'aborted' }),
};
const { container } = renderWithProviders(<DelegateLiveConsole streams={streams} />);
expect(container.firstChild).toBeNull();
});
it('入れ子(parentRunId あり)を子としてインデント描画', () => {
const streams = {
r1: entry({ delegateRunId: 'r1', description: '親', depth: 1 }),
r2: entry({ delegateRunId: 'r2', parentRunId: 'r1', description: '子', depth: 2 }),
};
renderWithProviders(<DelegateLiveConsole streams={streams} />);
expect(screen.getByText('親')).toBeTruthy();
expect(screen.getByText('子')).toBeTruthy();
});
});
@@ -0,0 +1,84 @@
import { useTranslation } from 'react-i18next';
import { delegateStatusBadge } from '../../lib/delegateRuns';
import type { DelegateStreamEntry } from '../../hooks/useJobStream';
interface ConsoleNode extends DelegateStreamEntry {
children: ConsoleNode[];
}
/** delegateStreams(フラット)を親子ツリーに。孤児は root 扱い(取りこぼし無し)。 */
function buildTree(streams: Record<string, DelegateStreamEntry>): ConsoleNode[] {
const nodes = new Map<string, ConsoleNode>();
for (const e of Object.values(streams)) nodes.set(e.delegateRunId, { ...e, children: [] });
const roots: ConsoleNode[] = [];
for (const node of nodes.values()) {
const parent = node.parentRunId ? nodes.get(node.parentRunId) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}
function ConsoleCard({ node }: { node: ConsoleNode }) {
const { t } = useTranslation('detail');
const badge = delegateStatusBadge(node.status);
const running = node.status === 'running';
return (
<div className="border border-hairline rounded-lg bg-canvas/60 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface/50 border-b border-hairline">
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium ${badge.cls}`}>
{t(badge.labelKey)}
</span>
<span className="text-[13px] text-slate-700 font-medium leading-tight truncate flex-1">
{node.description}
</span>
</div>
{(node.text || node.currentTool) && (
<div className="px-3 py-2 text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{node.currentTool ? (
<span className="text-slate-500 italic">
{t('subtasks.delegateRunningTool', { tool: node.currentTool })}
</span>
) : (
<>
{node.text}
{running && (
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
)}
</>
)}
</div>
)}
{node.children.length > 0 && (
<div className="pl-4 pr-2 pb-2 space-y-1.5">
{node.children.map((c) => <ConsoleCard key={c.delegateRunId} node={c} />)}
</div>
)}
</div>
);
}
/**
* delegate ライブコンソール: 実行中の delegate サブエージェントの出力を
* チャット欄に専用枠でストリーム表示する。streams が空なら何も出さない。
*
* 表示は status==='running' の run だけに絞る(delegate は直列実行なので
* 走っているのは1本+その入れ子のみ)。完了した run はチャットに溜めず、
* 履歴は「概要>サブ実行」が担う(ライブ=チャット / 履歴=概要 の役割分担)。
* これで 50 件連続スイープでもカードが積み上がらない。
*/
export function DelegateLiveConsole({ streams }: { streams: Record<string, DelegateStreamEntry> }) {
const running = Object.fromEntries(
Object.entries(streams).filter(([, e]) => e.status === 'running'),
);
const tree = buildTree(running);
if (tree.length === 0) return null;
return (
<div className="max-w-[85%] w-full space-y-1.5">
{tree.map((n) => <ConsoleCard key={n.delegateRunId} node={n} />)}
</div>
);
}
+12 -1
View File
@@ -105,9 +105,18 @@ interface MovementGroupExpandedProps {
isRunning: boolean;
animatingIdx: number;
startIdx: number;
/**
* inner の ChatMessage に渡す画像ベース URL の任意上書き。
* 未指定なら ChatMessage 側の既定(local raw URL)にフォールバックするため
* 本体(ChatPane)の呼び出しは従来どおり。共有ビューだけ
* `/api/shared/:token/files/raw?path=` を渡して画像参照を差し替える。
*/
imageBaseUrl?: string;
/** 共有ビューで添付チップ(input/ 配下・共有 API 非配信)を隠す。ChatMessage に委譲。 */
hideAttachments?: boolean;
}
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx }: MovementGroupExpandedProps) {
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, imageBaseUrl, hideAttachments }: MovementGroupExpandedProps) {
const { t } = useTranslation('chat');
const [expanded, setExpanded] = useState(false);
const { movementName, summary, inner } = item;
@@ -187,6 +196,8 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx }:
key={`c-${b.comment.id}`}
comment={b.comment}
taskId={taskId}
imageBaseUrl={imageBaseUrl}
hideAttachments={hideAttachments}
isStaleThinking={isThinkingComment(b.comment) && (startIdx + b.origIdx) !== animatingIdx}
/>
) : (
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
/**
* Component tests for ToolRequestApproval — the inline Approve/Deny card shown
* in chat when the agent paused on a RequestTool. Branching covered:
* - no pending requests → renders nothing
* - non-pending / non-"requested" → filtered out
* - pending "requested" rows → one card per tool, with reason + movement
* - Approve / Deny buttons → call decideToolRequest with the right args
* - mutation error → error row appears
* Network (fetchToolRequests / decideToolRequest) is fully mocked; i18n uses the
* real instance (auto-init on import) so labels resolve.
*/
import '../../test/dom-setup';
import '../../i18n';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import type { ToolRequest } from '../../api';
import * as api from '../../api';
import { ToolRequestApproval } from './ToolRequestApproval';
vi.mock('../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api')>();
return {
...actual,
fetchToolRequests: vi.fn(),
decideToolRequest: vi.fn(),
};
});
const mockedFetch = vi.mocked(api.fetchToolRequests);
const mockedDecide = vi.mocked(api.decideToolRequest);
function req(overrides: Partial<ToolRequest> = {}): ToolRequest {
return {
id: 'req-1',
taskId: '7',
jobId: 'job-1',
spaceId: null,
pieceName: 'chat',
movementName: 'execute',
toolName: 'WebSearch',
reason: 'need to look something up',
category: 'requested',
status: 'pending',
grantScope: null,
decidedBy: null,
createdAt: '2026-06-25T00:00:00Z',
decidedAt: null,
...overrides,
};
}
describe('ToolRequestApproval', () => {
beforeEach(() => {
mockedFetch.mockReset();
mockedDecide.mockReset();
});
it('renders nothing when there are no requests', async () => {
mockedFetch.mockResolvedValue([]);
const { container } = renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith(7));
expect(container.querySelector('[data-testid="tool-request-approval"]')).toBeNull();
});
it('filters out non-pending and non-"requested" rows', async () => {
mockedFetch.mockResolvedValue([
req({ id: 'a', status: 'approved' }),
req({ id: 'b', category: 'blocked' }),
]);
const { container } = renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
// both rows excluded → no card
expect(container.querySelector('[data-testid="tool-request-approval"]')).toBeNull();
});
it('renders a card per pending requested tool with reason + movement', async () => {
mockedFetch.mockResolvedValue([
req({ id: 'r1', toolName: 'WebSearch', reason: 'search the web', movementName: 'execute' }),
req({ id: 'r2', toolName: 'Bash', reason: null, movementName: 'verify' }),
]);
renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
expect(await screen.findByTestId('tool-request-WebSearch')).toBeInTheDocument();
expect(screen.getByTestId('tool-request-Bash')).toBeInTheDocument();
// tool name surfaces in the title (i18n interpolates {{tool}})
expect(screen.getByText(/WebSearch/)).toBeInTheDocument();
// reason shown for the one that has it
expect(screen.getByText(/search the web/)).toBeInTheDocument();
// movement label always shown
expect(screen.getByText('movement: execute')).toBeInTheDocument();
expect(screen.getByText('movement: verify')).toBeInTheDocument();
});
it('Approve calls decideToolRequest with approve + the request id', async () => {
mockedFetch.mockResolvedValue([req({ id: 'r1', toolName: 'WebSearch' })]);
mockedDecide.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
const approveBtn = await screen.findByTestId('tool-request-approve-WebSearch');
await user.click(approveBtn);
await waitFor(() => expect(mockedDecide).toHaveBeenCalledWith(7, 'r1', 'approve'));
});
it('Deny calls decideToolRequest with deny + the request id', async () => {
mockedFetch.mockResolvedValue([req({ id: 'r9', toolName: 'Bash' })]);
mockedDecide.mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
const denyBtn = await screen.findByTestId('tool-request-deny-Bash');
await user.click(denyBtn);
await waitFor(() => expect(mockedDecide).toHaveBeenCalledWith(7, 'r9', 'deny'));
});
it('shows an error row when the decide mutation rejects', async () => {
mockedFetch.mockResolvedValue([req({ id: 'r1', toolName: 'WebSearch' })]);
mockedDecide.mockRejectedValue(new Error('forbidden'));
const user = userEvent.setup();
renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
const approveBtn = await screen.findByTestId('tool-request-approve-WebSearch');
await user.click(approveBtn);
expect(await screen.findByTestId('tool-request-error')).toHaveTextContent('forbidden');
});
});
@@ -0,0 +1,86 @@
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchToolRequests, decideToolRequest } from '../../api';
/**
* Inline approval card shown in the chat when the agent is paused waiting for
* the user to approve/deny a tool it requested (RequestTool). Polls the task's
* tool requests while enabled and renders an approve/deny pair per pending row.
* Deciding re-queues the paused job, so the chat resumes on its own.
*/
export function ToolRequestApproval({ taskId, poll }: { taskId: number; poll: boolean }) {
const { t } = useTranslation('chat');
const qc = useQueryClient();
// Always fetch once when a task is open (so a pending approval card shows
// immediately on load); poll only while the job is active/paused.
const { data: requests = [] } = useQuery({
queryKey: ['tool-requests', taskId],
queryFn: () => fetchToolRequests(taskId),
refetchInterval: poll ? 3000 : false,
});
const decide = useMutation({
mutationFn: ({ reqId, decision }: { reqId: string; decision: 'approve' | 'deny' }) =>
decideToolRequest(taskId, reqId, decision),
onSuccess: () => {
// Refresh the request list (card disappears) and the task/job state so
// ChatPane's jobStatus flips off waiting_human immediately — otherwise the
// resume + stream reconnect lag until the next poll. Keys must match the
// app's actual query keys (see useTaskDetail / useTaskOperations).
qc.invalidateQueries({ queryKey: ['tool-requests', taskId] });
qc.invalidateQueries({ queryKey: ['localTask', taskId] });
qc.invalidateQueries({ queryKey: ['localTasks'] });
},
});
const pending = requests.filter((r) => r.status === 'pending' && r.category === 'requested');
if (pending.length === 0) return null;
return (
<div className="mb-3 space-y-2" data-testid="tool-request-approval">
{pending.map((r) => (
<div
key={r.id}
data-testid={`tool-request-${r.toolName}`}
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700/60 dark:bg-amber-900/20"
>
<div className="font-medium text-amber-900 dark:text-amber-200">
{t('toolRequest.title', { tool: r.toolName })}
</div>
{r.reason && (
<div className="mt-1 text-amber-800/90 dark:text-amber-200/80">
{t('toolRequest.reason')}: {r.reason}
</div>
)}
<div className="mt-0.5 text-xs text-amber-700/70 dark:text-amber-300/60">movement: {r.movementName}</div>
{decide.isError && (
<div className="mt-1 text-xs text-red-700 dark:text-red-300" data-testid="tool-request-error">
{t('toolRequest.failed')}: {(decide.error as Error)?.message ?? ''}
</div>
)}
<div className="mt-2 flex gap-2">
<button
type="button"
data-testid={`tool-request-approve-${r.toolName}`}
disabled={decide.isPending}
onClick={() => decide.mutate({ reqId: r.id, decision: 'approve' })}
className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
>
{t('toolRequest.approve')}
</button>
<button
type="button"
data-testid={`tool-request-deny-${r.toolName}`}
disabled={decide.isPending}
onClick={() => decide.mutate({ reqId: r.id, decision: 'deny' })}
className="rounded-md border border-stone-300 bg-white px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:hover:bg-stone-700"
>
{t('toolRequest.deny')}
</button>
</div>
</div>
))}
</div>
);
}
+15 -10
View File
@@ -216,9 +216,9 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
{/* Workspace mode (永続/一時的) + スペース選択 */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-4">
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.modeLabel')}</label>
<div className="inline-flex rounded-lg border border-slate-200 p-0.5">
{([['persistent', '永続(既定)'], ['ephemeral', '一時的']] as const).map(([mode, label]) => {
{([['persistent', t('workspace.persistent')], ['ephemeral', t('workspace.ephemeral')]] as const).map(([mode, label]) => {
const active = (form.workspaceMode ?? 'persistent') === mode;
return (
<button
@@ -235,16 +235,21 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
})}
</div>
<p className="text-2xs text-slate-400 mt-1">
==使
{t('workspace.modeHint')}
</p>
{(form.workspaceMode ?? 'persistent') === 'ephemeral' && (
<p className="text-2xs text-amber-600 mt-1" data-testid="ephemeral-warning">
{t('workspace.ephemeralWarning')}
</p>
)}
</div>
{/* スペース: 固定 or ピッカー(既定=個人) */}
<div className="min-w-0 flex-1">
<label className="block text-2xs text-slate-500 mb-1"></label>
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.spaceLabel')}</label>
{initialSpaceId ? (
<div className="px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs text-slate-700 bg-slate-50 truncate">
{fixedSpace?.title ?? 'このワークスペース'}
{fixedSpace?.title ?? t('workspace.thisWorkspace')}
</div>
) : (
<select
@@ -252,7 +257,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
onChange={e => setSelectedSpaceId(e.target.value || undefined)}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value=""></option>
<option value="">{t('workspace.personalDefault')}</option>
{sortedSpaces
.filter(s => s.kind === 'case')
.map(s => (
@@ -327,7 +332,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.profile')}</label>
<select
value={form.profile}
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value }))}
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value as CreateLocalTaskInput['profile'] }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
{[['auto', 'auto'], ['fast', 'fast'], ['quality', 'quality']].map(([v, l]) => (
@@ -339,7 +344,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.priority')}</label>
<select
value={form.priority}
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value }))}
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value as CreateLocalTaskInput['priority'] }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
{[['low', 'low'], ['medium', 'medium'], ['high', 'high']].map(([v, l]) => (
@@ -355,7 +360,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.outputFormat')}</label>
<select
value={form.outputFormat}
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value }))}
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value as CreateLocalTaskInput['outputFormat'] }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
{[['markdown', 'markdown'], ['text', 'text'], ['json', 'json']].map(([v, l]) => (
@@ -367,7 +372,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.askPolicy')}</label>
<select
value={form.askPolicy}
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value }))}
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value as CreateLocalTaskInput['askPolicy'] }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value="low">{t('advanced.askLow')}</option>
@@ -1,60 +0,0 @@
import { useRef, useState } from 'react';
import { useSidePanelLayout } from '../../hooks/useSidePanelLayout';
import { VerticalResizeHandle } from '../layout/VerticalResizeHandle';
import { SideInfoPanel } from './SideInfoPanel';
interface Props {
/** TaskListPanel または RailPanel を含む上半分。 */
upper: React.ReactNode;
activeWidgetSlug?: string;
onActiveWidgetSlugChange?: (slug: string) => void;
/** rail/mobile 等の狭い viewport で default を collapsed にしたい場合に指定。 */
defaultCollapsed?: boolean;
}
let _idSeq = 0;
export function TaskListWithSidePanel({
upper,
activeWidgetSlug,
onActiveWidgetSlugChange,
defaultCollapsed,
}: Props) {
const { listHeightPct, setListHeightPct, collapsed, toggleCollapsed, resetHeight } = useSidePanelLayout();
const initOverrideRef = useRef<boolean>(false);
if (defaultCollapsed && !initOverrideRef.current && localStorage.getItem('dashboard.collapsed') === null) {
initOverrideRef.current = true;
toggleCollapsed();
}
const [parentId] = useState(() => `tlspl-${++_idSeq}`);
const upperFlex = collapsed ? '1 1 auto' : `0 0 ${listHeightPct}%`;
const lowerFlex = collapsed ? '0 0 auto' : `0 0 ${100 - listHeightPct}%`;
return (
<div
data-side-panel-parent={parentId}
className="flex flex-col h-full min-h-0 overflow-hidden"
>
<div style={{ flex: upperFlex, minHeight: 0 }} className="overflow-hidden">
{upper}
</div>
{!collapsed && (
<VerticalResizeHandle
parentSelector={`[data-side-panel-parent="${parentId}"]`}
onResize={setListHeightPct}
onResizeEnd={setListHeightPct}
onReset={resetHeight}
/>
)}
<div style={{ flex: lowerFlex, minHeight: collapsed ? 'auto' : 0 }} className="overflow-hidden border-t border-hairline">
<SideInfoPanel
activeSlug={activeWidgetSlug}
onActiveSlugChange={onActiveWidgetSlugChange}
collapsed={collapsed}
onToggleCollapse={toggleCollapsed}
/>
</div>
</div>
);
}
+30 -1
View File
@@ -1,7 +1,14 @@
import { useTranslation } from 'react-i18next';
interface ContextUsageGaugeProps {
promptTokens?: number | null;
limitTokens?: number | null;
jobStatus?: string;
/**
* compact: 入力欄の直上に常時表示する低プロファイルのバー。概要タブのカード型
* (既定)と同じ色・比率ロジックを共有しつつ、薄い 1 行表示にする。
*/
compact?: boolean;
}
function formatNumber(n: number): string {
@@ -29,16 +36,38 @@ function pickLabel(jobStatus: string | undefined): string {
}
}
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus }: ContextUsageGaugeProps) {
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus, compact }: ContextUsageGaugeProps) {
const { t } = useTranslation('detail');
if (!limitTokens || limitTokens <= 0) return null;
const tokens = typeof promptTokens === 'number' ? promptTokens : 0;
const awaiting = typeof promptTokens !== 'number';
const ratio = Math.min(1, Math.max(0, tokens / limitTokens));
const percent = Math.round(ratio * 100);
const remaining = Math.max(0, limitTokens - tokens);
const colorClass = pickColorClass(ratio);
const label = pickLabel(jobStatus);
if (compact) {
return (
<div
className="flex items-center gap-2 text-2xs text-slate-500 tabular-nums"
title={`${formatNumber(tokens)} / ${formatNumber(limitTokens)} tokens`}
aria-label={t('context.ariaLabel', { remaining: formatNumber(remaining), percent })}
>
<div className="h-1.5 flex-1 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full ${colorClass} transition-[width] duration-300 ease-out`}
style={{ width: `${percent}%` }}
/>
</div>
<span className="shrink-0">
{awaiting ? t('context.awaiting') : t('context.remaining', { remaining: formatNumber(remaining), percent })}
</span>
</div>
);
}
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex items-baseline justify-between mb-2">
+8 -3
View File
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DetailTabId } from '../../lib/urlState';
import { shareTask, unshareTask } from '../../api';
import { tabAppearClass } from './detailTabs';
import { showHeaderActions } from './detail-readonly';
interface Tab { id: DetailTabId; labelKey: string; }
@@ -30,6 +31,9 @@ interface DetailHeaderProps {
/** Click handler for the Continue button. When undefined, the button is
* hidden entirely (e.g., shared/read-only views). */
onContinue?: () => void;
/** When true, hide all mutating actions (share / continue). Delete lives in
* the panel footer and is gated there. Default false = unchanged. */
readonly?: boolean;
}
export function ShareButton({ taskId, shareToken, onShareChange, testid }: { taskId: number; shareToken: string | null; onShareChange?: () => void; testid?: string }) {
@@ -169,8 +173,9 @@ export function ContinueButton({ latestJobStatus, onClick, testid }: { latestJob
);
}
export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPending, onTabChange, onClose, detailWidth, onWidthToggle, taskId, shareToken, onShareChange, latestJobStatus, onContinue }: DetailHeaderProps) {
export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPending, onTabChange, onClose, detailWidth, onWidthToggle, taskId, shareToken, onShareChange, latestJobStatus, onContinue, readonly = false }: DetailHeaderProps) {
const { t } = useTranslation('detail');
const actionsVisible = showHeaderActions(readonly);
// Mobile (< sm) hides the close button and tab bar because App.tsx
// renders its own mobile-level top tab bar with the same controls.
// Two close buttons / two tab bars on iPhone was visually redundant.
@@ -185,13 +190,13 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
now icon-only (32px) so it fits next to the title instead of
occupying its own row. */}
<div className="flex items-center gap-1 flex-shrink-0">
{onContinue && taskId != null && (
{actionsVisible && onContinue && taskId != null && (
<ContinueButton
latestJobStatus={latestJobStatus ?? null}
onClick={onContinue}
/>
)}
{taskId != null && (
{actionsVisible && taskId != null && (
<ShareButton
taskId={taskId}
shareToken={shareToken ?? null}
+50 -9
View File
@@ -19,6 +19,8 @@ import { ConsoleTab } from './tabs/ConsoleTab';
import { BrowserSessionPanel } from '../browser/BrowserSessionPanel';
import { useAuthState } from '../../App';
import type { SubtaskFilePreviewHandler } from './tabs/SubtasksPanel';
import { showVisibilityEdit, showDelete } from './detail-readonly';
import { sharingPreview, visibilityTooltip } from '../../lib/sharingScope';
interface LocalDetailPanelProps {
task: LocalTask | null;
@@ -51,6 +53,10 @@ interface LocalDetailPanelProps {
* the active tab body. Used by the space chat single-tab layout where the
* tab bar lives outside this panel. Default false = unchanged (Tasks page). */
headerless?: boolean;
/** When true, render a read-only mirror: no title/visibility/feedback edit,
* no delete/share/continue actions, no dependence on useAuthState for edit
* gating. Used by the public SharedView. Default false = unchanged. */
readonly?: boolean;
}
@@ -59,7 +65,7 @@ export function LocalDetailPanel({
loading, detailTab, detailWidth, showWidthToggle,
onTabChange, onWidthToggle, onClose, onDelete, onSectionChange, onNavigate, onPreview, onViewFullLog,
onRefresh, isRefreshing, fileManagement, subtaskActivities, onSubtaskFilePreview,
shareToken, onShareChange, headerless = false,
shareToken, onShareChange, headerless = false, readonly = false,
}: LocalDetailPanelProps) {
const { t } = useTranslation('detail');
// Deferred tab id for content rendering. The tab indicator (DetailHeader)
@@ -80,9 +86,13 @@ export function LocalDetailPanel({
const authState = useAuthState();
const currentUserId = authState.mode === 'authenticated' ? authState.user.id : null;
const currentUserRole = authState.mode === 'authenticated' ? authState.user.role : null;
const canEditVisibility = task
? (currentUserRole === 'admin' || (currentUserId !== null && task.ownerId === currentUserId))
: false;
// readonly(共有ページ)では認証状態に依存せず、可視性編集を常に隠す。
const canEditVisibility = showVisibilityEdit(
readonly,
task
? (currentUserRole === 'admin' || (currentUserId !== null && task.ownerId === currentUserId))
: false,
);
const { data: orgs = [] } = useQuery({
queryKey: ['my-orgs'],
queryFn: fetchMyOrgs,
@@ -152,6 +162,7 @@ export function LocalDetailPanel({
onShareChange={onShareChange}
latestJobStatus={task?.latestJob?.status ?? null}
onContinue={task?.latestJob ? () => setContinueOpen(true) : undefined}
readonly={readonly}
/>
)}
{continueOpen && task?.latestJob && (
@@ -190,7 +201,7 @@ export function LocalDetailPanel({
{editingVisibility && (
<div className="mb-3 p-2.5 border border-hairline rounded-md bg-canvas text-xs">
<div className="flex gap-3 flex-wrap">
<label className="flex items-center gap-1">
<label className="flex items-center gap-1" title={visibilityTooltip('private')}>
<input
type="radio"
checked={editVisibility === 'private'}
@@ -198,7 +209,7 @@ export function LocalDetailPanel({
/>
🔒 {t('visibility.private')}
</label>
<label className="flex items-center gap-1">
<label className="flex items-center gap-1" title={visibilityTooltip('org')}>
<input
type="radio"
checked={editVisibility === 'org'}
@@ -210,7 +221,7 @@ export function LocalDetailPanel({
/>
🏢 {t('visibility.org')}
</label>
<label className="flex items-center gap-1">
<label className="flex items-center gap-1" title={visibilityTooltip('public')}>
<input
type="radio"
checked={editVisibility === 'public'}
@@ -234,6 +245,36 @@ export function LocalDetailPanel({
{editVisibility === 'org' && orgs.length === 0 && (
<div className="mt-1 text-2xs text-slate-400">{t('visibility.orgLoginHint')}</div>
)}
{(() => {
const orgName = editVisibility === 'org'
? (orgs.find(o => o.orgId === editScopeOrgId)?.orgName ?? orgs[0]?.orgName ?? null)
: null;
const preview = sharingPreview(editVisibility, orgName);
return (
<div
data-testid="visibility-sharing-preview"
className="mt-2.5 p-2 rounded-md bg-surface border border-hairline/70 text-2xs"
>
<div className="text-slate-600">👥 {preview.audience}</div>
{editVisibility !== 'private' && (
<>
<ul className="mt-1.5 space-y-0.5">
{preview.items.map((it) => (
<li key={it.label} className="flex items-center gap-1">
{it.shared
? <span className="text-green-600" aria-hidden></span>
: <span className="text-slate-400" aria-hidden>🔒</span>}
<span className={it.shared ? 'text-slate-700' : 'text-slate-500'}>{it.label}</span>
{!it.shared && <span className="text-slate-400">{t('sharingPreview.notShared')}</span>}
</li>
))}
</ul>
<div className="mt-1.5 text-slate-500">👁 {preview.accessNote}</div>
</>
)}
</div>
);
})()}
{editError && <div className="mt-1 text-2xs text-red-600">{editError}</div>}
<div className="mt-2 flex gap-2">
<button
@@ -256,7 +297,7 @@ export function LocalDetailPanel({
{task?.latestJob?.status === 'waiting_human' && task?.latestJob?.waitReason === 'browser_login' && (
<BrowserSessionPanel />
)}
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} />}
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} readonly={readonly} />}
{deferredDetailTab === 'activity' && <ProgressTab task={task} onViewFullLog={onViewFullLog} subtaskActivities={subtaskActivities} />}
{deferredDetailTab === 'files' && <FilesTab section={section} currentPath={currentPath} entries={entries} pathSegments={pathSegments} taskId={taskId} onSectionChange={onSectionChange} onNavigate={onNavigate} onPreview={onPreview} onRefresh={onRefresh} isRefreshing={isRefreshing} management={fileManagement} />}
{deferredDetailTab === 'trace' && <TraceTab taskId={taskId} />}
@@ -265,7 +306,7 @@ export function LocalDetailPanel({
</>
)}
</div>
{!loading && task && (
{!loading && task && showDelete(readonly) && (
<div className="flex-shrink-0 border-t border-hairline bg-canvas px-3 py-2.5">
<div className="flex gap-2 items-center">
{onDelete && !isActiveJob ? (
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
/**
* Component tests for ReflectionBadge — the "🧠 Learned N things" pill on the
* Overview tab. The badge only appears when the latest reflection for a task
* actually changed something. Verifies: hidden when no reflection; hidden when
* outcome is abstained/failed; hidden when 0 memory changes and no piece edit;
* shown with correct singular/plural label; shows "+ piece edit" suffix; links
* to the memory-learning settings section anchored at the snapshot.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../test/render-helpers';
import type { LatestReflectionForTask } from '../../api';
import * as api from '../../api';
import { ReflectionBadge } from './ReflectionBadge';
// Mock just the one network call the badge makes.
vi.mock('../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api')>();
return { ...actual, getLatestReflectionForTask: vi.fn() };
});
const mockedGet = vi.mocked(api.getLatestReflectionForTask);
function reflection(overrides: Partial<LatestReflectionForTask> = {}): LatestReflectionForTask {
return {
snapshotId: 'snap-123',
outcome: 'applied',
memoryChanges: 2,
pieceEdited: false,
...overrides,
};
}
describe('ReflectionBadge', () => {
beforeEach(() => {
mockedGet.mockReset();
});
it('renders nothing when there is no reflection', async () => {
mockedGet.mockResolvedValue(null);
const { container } = renderWithProviders(<ReflectionBadge taskId={1} />);
// Give the (resolved-null) query a tick; badge must stay empty.
await waitFor(() => expect(mockedGet).toHaveBeenCalled());
expect(container.querySelector('a')).toBeNull();
});
it('renders nothing when the outcome is abstained', async () => {
mockedGet.mockResolvedValue(reflection({ outcome: 'abstained', memoryChanges: 3 }));
const { container } = renderWithProviders(<ReflectionBadge taskId={2} />);
await waitFor(() => expect(mockedGet).toHaveBeenCalled());
expect(container.querySelector('a')).toBeNull();
});
it('renders nothing when 0 memory changes and no piece edit', async () => {
mockedGet.mockResolvedValue(reflection({ memoryChanges: 0, pieceEdited: false }));
const { container } = renderWithProviders(<ReflectionBadge taskId={3} />);
await waitFor(() => expect(mockedGet).toHaveBeenCalled());
expect(container.querySelector('a')).toBeNull();
});
it('shows the pluralized label when reflection changed multiple things', async () => {
mockedGet.mockResolvedValue(reflection({ memoryChanges: 2 }));
renderWithProviders(<ReflectionBadge taskId={4} />);
const link = await screen.findByRole('link');
expect(link).toHaveTextContent('🧠 Learned 2 things');
expect(link).not.toHaveTextContent('piece edit');
});
it('uses the singular "thing" for exactly one memory change', async () => {
mockedGet.mockResolvedValue(reflection({ memoryChanges: 1 }));
renderWithProviders(<ReflectionBadge taskId={5} />);
const link = await screen.findByRole('link');
expect(link).toHaveTextContent('Learned 1 thing');
expect(link).not.toHaveTextContent('things');
});
it('appends "+ piece edit" and links to the snapshot when a piece was edited', async () => {
mockedGet.mockResolvedValue(reflection({ memoryChanges: 0, pieceEdited: true }));
renderWithProviders(<ReflectionBadge taskId={6} />);
const link = await screen.findByRole('link');
expect(link).toHaveTextContent('Learned 0 things + piece edit');
expect(link).toHaveAttribute(
'href',
'?page=settings&section=memory-learning#snapshot-snap-123',
);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
showVisibilityEdit,
showTitleEdit,
showFeedback,
showMissionEdit,
showHeaderActions,
showDelete,
} from './detail-readonly';
describe('detail read-only predicates', () => {
it('hides all edit affordances when read-only', () => {
expect(showVisibilityEdit(true, true)).toBe(false);
expect(showTitleEdit(true)).toBe(false);
expect(showFeedback(true)).toBe(false);
expect(showMissionEdit(true)).toBe(false);
expect(showHeaderActions(true)).toBe(false);
expect(showDelete(true)).toBe(false);
});
it('keeps authed (readonly=false) behavior unchanged', () => {
// 可視性編集は権限次第(従来どおり)
expect(showVisibilityEdit(false, true)).toBe(true);
expect(showVisibilityEdit(false, false)).toBe(false);
// 残りは authed では常に表示
expect(showTitleEdit(false)).toBe(true);
expect(showFeedback(false)).toBe(true);
expect(showMissionEdit(false)).toBe(true);
expect(showHeaderActions(false)).toBe(true);
expect(showDelete(false)).toBe(true);
});
});
@@ -0,0 +1,34 @@
// 詳細パネルの read-only 分岐(純ロジック)。
// 共有ページ(公開・認証なし)では編集系 UI を一切出さない。本体(認証版)は
// readonly 既定 false なので従来どおり。各コンポーネントはこの述語を使い、
// readonly のとき mutation を呼ぶ要素自体を描画しない。
/** 可視性編集(private/org/public 切替)を出すか。readonly では常に隠す。 */
export function showVisibilityEdit(readonly: boolean, canEditVisibility: boolean): boolean {
return !readonly && canEditVisibility;
}
/** タイトル編集・再生成 UI を出すか。readonly では隠す。 */
export function showTitleEdit(readonly: boolean): boolean {
return !readonly;
}
/** フィードバック投稿 UI を出すか。readonly では隠す。 */
export function showFeedback(readonly: boolean): boolean {
return !readonly;
}
/** Mission Brief の編集 UI を出すか。readonly では閲覧のみ。 */
export function showMissionEdit(readonly: boolean): boolean {
return !readonly;
}
/** ヘッダーの破壊的/共有/再実行アクション(削除・共有・Continue)を出すか。 */
export function showHeaderActions(readonly: boolean): boolean {
return !readonly;
}
/** フッターの削除ボタンを出すか。readonly では隠す。 */
export function showDelete(readonly: boolean): boolean {
return !readonly;
}
@@ -0,0 +1,102 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { POLLING } from '../../../lib/constants.js';
import { fetchDelegateRuns, fetchDelegateRunTimeline, type TraceEventLite } from '../../../api';
import { buildDelegateRunTree, delegateStatusBadge, formatElapsed, type DelegateRunNode, type DelegateRun } from '../../../lib/delegateRuns';
import { useNow } from '../../../hooks/useNow';
import { summarizeTraceEvent } from '../../../lib/traceEvent';
function EventLine({ event }: { event: TraceEventLite }) {
const summary = summarizeTraceEvent(event);
return (
<div className="flex items-baseline gap-1.5 py-0.5 font-mono text-[10px] text-slate-500">
<span className="shrink-0 text-slate-400">{new Date(event.ts).toLocaleTimeString()}</span>
<span className="shrink-0 font-semibold text-slate-600">{event.kind}</span>
{summary && <span className="truncate text-slate-400">{summary}</span>}
</div>
);
}
function RunCard({ taskId, node, indent = 0 }: { taskId: number; node: DelegateRunNode; indent?: number }) {
const { t } = useTranslation('detail');
const [open, setOpen] = useState(false);
const badge = delegateStatusBadge(node.status);
const running = node.status === 'running';
// 実行中カードのみ 1 秒刻みで経過を更新(ネットワーク不要のローカルタイマー)。
const now = useNow(running);
const { data: events } = useQuery({
queryKey: ['delegate-run-timeline', taskId, node.delegateRunId],
queryFn: () => fetchDelegateRunTimeline(taskId, node.delegateRunId),
enabled: open,
// 開いていて実行中の間だけ自動更新。完了したら停止(done な run のイベントは不変)。
refetchInterval: open && running ? POLLING.FAST : false,
});
return (
<div
className="border border-slate-200 rounded-md mb-1.5 overflow-hidden"
style={indent > 0 ? { marginLeft: `${indent * 16}px` } : undefined}
>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-slate-50 transition-colors"
onClick={() => setOpen((v) => !v)}
>
<span className={`shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full ${badge.cls}`}>
{t(badge.labelKey)}
</span>
<span className="text-[13px] text-slate-800 font-medium truncate flex-1">
{node.description || '(no description)'}
</span>
<span className="shrink-0 text-xs text-slate-400">
depth {node.depth} · {node.toolCalls} tools · {formatElapsed(node.startTs, node.endTs, now)}
</span>
<span className="shrink-0 text-slate-400 text-xs ml-1">{open ? '▲' : '▼'}</span>
</button>
{open && (
<div className="border-t border-slate-100 px-3 pb-2">
{events && events.length > 0 ? (
<div className="mt-1">
{events.map((e) => <EventLine key={e.eventId} event={e} />)}
</div>
) : (
<div className="mt-1 text-[11px] text-slate-400">
{events ? t('delegateRuns.eventsEmpty') : t('common:loading')}
</div>
)}
{node.children.map((c) => (
<RunCard key={c.delegateRunId} taskId={taskId} node={c} indent={indent + 1} />
))}
</div>
)}
</div>
);
}
export function DelegateRunsSection({ taskId }: { taskId: number }) {
const { t } = useTranslation('detail');
const { data: runs } = useQuery({
queryKey: ['delegate-runs', taskId],
queryFn: () => fetchDelegateRuns(taskId),
// 実行中の run がある間は速く(FAST=5s)、無ければ通常(MEDIUM=10s)。
// refetchIntervalInBackground は既定 false なのでタブ非表示時は自動停止。
refetchInterval: (query) => {
const data = query.state.data as DelegateRun[] | undefined;
return data?.some((r) => r.status === 'running') ? POLLING.FAST : POLLING.MEDIUM;
},
});
const tree = buildDelegateRunTree(runs ?? []);
if (tree.length === 0) return null;
return (
<div className="mb-4">
<div className="text-sm font-bold text-slate-800 mb-2">{t('subtasks.delegateSection')}</div>
{tree.map((n) => (
<RunCard key={n.delegateRunId} taskId={taskId} node={n} />
))}
</div>
);
}
@@ -1,37 +0,0 @@
import { LinkifiedText } from '../../../lib/linkified-text';
import { useTranslation } from 'react-i18next';
interface OutputTabProps {
outputPreviewName: string;
outputPreviewContent: string;
onViewFull: () => void;
}
export function OutputTab({ outputPreviewName, outputPreviewContent, onViewFull }: OutputTabProps) {
const { t } = useTranslation('detail');
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex justify-between items-center mb-2">
<div className="font-bold text-[13px] text-slate-800">{t('output.title')}</div>
{outputPreviewName && (
<button onClick={onViewFull} className="text-2xs text-blue-600 font-bold hover:underline">{t('output.viewFull')}</button>
)}
</div>
{outputPreviewName ? (
<>
<div className="text-2xs text-slate-400 mb-2 font-mono">{outputPreviewName}</div>
{/* LinkifiedText turns inline `output/foo.md` references into
clickable anchors that the OutputPreviewProvider opens in
the preview pane. Plain `<pre>` rendering otherwise. */}
<LinkifiedText
as="pre"
className="text-xs whitespace-pre-wrap bg-slate-50 rounded-xl p-3 min-h-[260px] max-h-[540px] overflow-auto border border-slate-100"
text={outputPreviewContent.slice(0, 12000)}
/>
</>
) : (
<div className="text-[13px] text-slate-500">{t('output.empty')}</div>
)}
</div>
);
}
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
/**
* Component tests for OverviewTab — the task summary tab. Verifies the title +
* status badge + piece/priority chips render, the body shows (with the
* "(no body)" fallback), the FeedbackPanel only appears for completed jobs, and
* the "🧠 Learned N things" reflection badge surfaces only when the latest
* reflection actually changed something. Network calls (reflection, delegate
* runs) are mocked so the tab mounts in isolation.
*/
import '../../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../../../test/render-helpers';
import '../../../i18n';
import type { LocalTask, LatestReflectionForTask } from '../../../api';
import * as api from '../../../api';
import { OverviewTab } from './OverviewTab';
vi.mock('../../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api')>();
return {
...actual,
getLatestReflectionForTask: vi.fn().mockResolvedValue(null),
fetchDelegateRuns: vi.fn().mockResolvedValue([]),
};
});
const mockedReflection = vi.mocked(api.getLatestReflectionForTask);
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
return {
id: 10,
title: 'Quarterly review',
body: 'Analyze last quarter performance.',
pieceName: 'analysis',
profile: 'default',
outputFormat: 'markdown',
askPolicy: 'auto',
priority: 'high',
state: 'open',
workspacePath: null,
createdAt: '2026-06-01T00:00:00Z',
updatedAt: '2026-06-01T00:00:00Z',
latestJob: { id: 'job-10', status: 'running' },
...overrides,
};
}
function reflection(overrides: Partial<LatestReflectionForTask> = {}): LatestReflectionForTask {
return { snapshotId: 's1', outcome: 'applied', memoryChanges: 3, pieceEdited: false, ...overrides };
}
describe('OverviewTab', () => {
it('renders the title, status badge and piece/priority chips', async () => {
mockedReflection.mockResolvedValue(null);
renderWithProviders(<OverviewTab task={makeTask()} />);
expect(screen.getByText('Quarterly review')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.getByText('analysis')).toBeInTheDocument();
expect(screen.getByText('high')).toBeInTheDocument();
});
it('renders the body, and the (no body) fallback when empty', async () => {
mockedReflection.mockResolvedValue(null);
const { rerender } = renderWithProviders(<OverviewTab task={makeTask()} />);
expect(screen.getByText('Analyze last quarter performance.')).toBeInTheDocument();
rerender(<OverviewTab task={makeTask({ body: '' })} />);
expect(screen.getByText('(no body)')).toBeInTheDocument();
});
it('hides the feedback panel while the job is still running', async () => {
mockedReflection.mockResolvedValue(null);
renderWithProviders(<OverviewTab task={makeTask({ latestJob: { id: 'j', status: 'running' } })} />);
// FeedbackPanel returns null until the job is complete.
expect(screen.queryByText('Feedback')).not.toBeInTheDocument();
});
it('shows the feedback panel once the job has succeeded', async () => {
mockedReflection.mockResolvedValue(null);
renderWithProviders(<OverviewTab task={makeTask({ latestJob: { id: 'j', status: 'succeeded' } })} />);
expect(screen.getByText('Feedback')).toBeInTheDocument();
});
it('does not show the reflection badge when no reflection changed anything', async () => {
mockedReflection.mockResolvedValue(null);
renderWithProviders(<OverviewTab task={makeTask()} />);
await waitFor(() => expect(mockedReflection).toHaveBeenCalled());
expect(screen.queryByText(/Learned/)).not.toBeInTheDocument();
});
it('shows the "🧠 Learned N things" badge when reflection applied changes', async () => {
mockedReflection.mockResolvedValue(reflection({ memoryChanges: 3 }));
renderWithProviders(<OverviewTab task={makeTask()} />);
expect(await screen.findByText(/Learned 3 things/)).toBeInTheDocument();
});
});
+43 -8
View File
@@ -4,13 +4,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief, updateLocalTask, regenerateTaskTitle } from '../../../api';
import { StatusBadge } from '../../shared/StatusBadge';
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
import { DelegateRunsSection } from './DelegateRunsSection';
import { ContextUsageGauge } from '../ContextUsageGauge';
import { ReflectionBadge } from '../ReflectionBadge';
import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly';
const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '指示をよく理解していた', '速度が適切だった'];
const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた'];
function FeedbackPanel({ task }: { task: LocalTask }) {
function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed';
@@ -32,6 +34,29 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
});
if (!isComplete) return null;
// read-only(共有ページ): 投稿/編集 UI は出さない。フィードバック未登録なら
// 何も描画しない。登録済みでも change ボタン(mutation 経路)を出さない。
if (readonly && !showFeedback(readonly)) {
if (!hasFeedback) return null;
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-700">{t('feedback.title')}</span>
<span className={`text-lg ${task.feedbackRating === 'good' ? 'text-green-500' : 'text-red-500'}`}>
{task.feedbackRating === 'good' ? '👍' : '👎'}
</span>
</div>
{task.feedbackTags && task.feedbackTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{task.feedbackTags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tag}</span>
))}
</div>
)}
{task.feedbackComment && <div className="mt-2 text-xs text-slate-500">{task.feedbackComment}</div>}
</div>
);
}
const tags = rating === 'good' ? GOOD_TAGS : rating === 'bad' ? BAD_TAGS : [];
const toggleTag = (tag: string) => {
@@ -163,7 +188,7 @@ const MISSION_FIELDS: Array<{ key: keyof MissionBrief }> = [
const EMPTY_MISSION: MissionBrief = { goal: '', done: '', open: '', clarifications: '' };
function MissionCard({ task }: { task: LocalTask }) {
function MissionCard({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const current = task.missionBrief ?? EMPTY_MISSION;
@@ -191,6 +216,8 @@ function MissionCard({ task }: { task: LocalTask }) {
});
const isEmpty = !current.goal && !current.done && !current.open && !current.clarifications;
// read-only(共有): 空の Mission Brief は編集導線が主目的なので丸ごと隠す。
if (readonly && isEmpty) return null;
return (
<div className="bg-canvas border border-hairline rounded-md p-3.5">
@@ -202,7 +229,7 @@ function MissionCard({ task }: { task: LocalTask }) {
<span className="section-label">Mission Brief</span>
<span className="text-[10px] text-slate-400"> {t('mission.pinnedMemo')}</span>
</div>
{!editing ? (
{showMissionEdit(readonly) && !editing ? (
<button
type="button"
onClick={() => { setDraft(current); setEditing(true); setError(null); }}
@@ -278,7 +305,7 @@ function MissionCard({ task }: { task: LocalTask }) {
* Mission Brief goal) during the run. A manual edit pins it (title_source =
* 'user') so the agent never overwrites it afterwards.
*/
function TaskTitleRow({ task }: { task: LocalTask }) {
function TaskTitleRow({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const [editing, setEditing] = useState(false);
@@ -351,6 +378,7 @@ function TaskTitleRow({ task }: { task: LocalTask }) {
<div>
<div className="group flex items-start justify-between gap-2">
<div className="text-lg font-extrabold text-slate-900 break-words leading-tight min-w-0">{task.title}</div>
{showTitleEdit(readonly) && (
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
@@ -376,6 +404,7 @@ function TaskTitleRow({ task }: { task: LocalTask }) {
</svg>
</button>
</div>
)}
</div>
{error && <div className="text-2xs text-red-600 mt-1">{error}</div>}
</div>
@@ -385,16 +414,17 @@ function TaskTitleRow({ task }: { task: LocalTask }) {
interface OverviewTabProps {
task: LocalTask;
subtaskActivities?: SubtaskActivity[];
readonly?: boolean;
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
}
export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: OverviewTabProps) {
export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview, readonly = false }: OverviewTabProps) {
const status = task.latestJob?.status ?? 'queued';
return (
<div className="flex flex-col gap-3">
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<TaskTitleRow task={task} />
<TaskTitleRow task={task} readonly={readonly} />
<div className="flex flex-wrap gap-2 mt-2">
<StatusBadge status={status} />
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>
@@ -403,7 +433,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
<div className="mt-3 text-[13px] text-slate-600 whitespace-pre-wrap leading-relaxed">{task.body || '(no body)'}</div>
</div>
<MissionCard task={task} />
<MissionCard task={task} readonly={readonly} />
<ContextUsageGauge
promptTokens={task.latestJob?.contextPromptTokens}
@@ -411,7 +441,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
jobStatus={task.latestJob?.status}
/>
<FeedbackPanel task={task} />
<FeedbackPanel task={task} readonly={readonly} />
<ReflectionBadge taskId={task.id} />
@@ -425,6 +455,11 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
onFilePreview={onSubtaskFilePreview}
/>
)}
{/* delegate サブ実行: SpawnSubTask の有無に関係なく表示(runが無ければ自己非表示)。
SubtasksPanel は subtasks.length>0 でしかマウントされないため、delegate のみの
タスクでも見えるよう独立して描画する。 */}
<DelegateRunsSection taskId={task.id} />
</div>
);
}
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
/**
* Component tests for ProgressTab — the execution timeline + raw activity.log
* view. Verifies the parsed-event count renders, the current-movement line
* reflects job state (and its pending fallback), the empty timeline label shows
* when there are no events, the raw activity.log body renders, and the
* "view full log" control fires its callback.
*/
import '../../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../test/render-helpers';
import '../../../i18n';
import type { LocalTask } from '../../../api';
import * as taskDetailHooks from '../../../hooks/useTaskDetail';
import { ProgressTab } from './ProgressTab';
// Control the activity.log content without touching the network.
vi.mock('../../../hooks/useTaskDetail', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../hooks/useTaskDetail')>();
return { ...actual, useLocalActivityLog: vi.fn() };
});
const mockedHook = vi.mocked(taskDetailHooks.useLocalActivityLog);
function mockLog(data: string, isLoading = false) {
// Only the .data + .isLoading fields are read by ProgressTab.
mockedHook.mockReturnValue({ data, isLoading } as unknown as ReturnType<
typeof taskDetailHooks.useLocalActivityLog
>);
}
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
return {
id: 1,
title: 'T',
body: '',
pieceName: 'chat',
profile: 'default',
outputFormat: 'markdown',
askPolicy: 'auto',
priority: 'normal',
state: 'open',
workspacePath: null,
createdAt: '2026-06-01T00:00:00Z',
updatedAt: '2026-06-01T00:00:00Z',
latestJob: { id: 'job-1', status: 'running', currentMovement: 'analyze' },
...overrides,
};
}
describe('ProgressTab', () => {
it('renders the current movement line from the job state', () => {
mockLog('');
renderWithProviders(
<ProgressTab task={makeTask({ latestJob: { id: 'j', status: 'running', currentMovement: 'analyze' } })} onViewFullLog={() => {}} />,
);
expect(screen.getByText(/analyze/)).toBeInTheDocument();
});
it('shows the empty-timeline label when the log has no events', () => {
mockLog('');
renderWithProviders(<ProgressTab task={makeTask()} onViewFullLog={() => {}} />);
expect(screen.getByText('No progress yet.')).toBeInTheDocument();
// 0 events counter.
expect(screen.getByText(/0 events/)).toBeInTheDocument();
});
it('renders the raw activity.log content in the pre block', () => {
mockLog('hello from the activity log');
const { container } = renderWithProviders(<ProgressTab task={makeTask()} onViewFullLog={() => {}} />);
// The raw log is rendered verbatim inside the <pre> block (the timeline may
// also echo parsed lines, so scope the assertion to the pre element).
const pre = container.querySelector('pre');
expect(pre).not.toBeNull();
expect(pre!.textContent).toContain('hello from the activity log');
});
it('fires onViewFullLog when the view-full control is clicked', async () => {
mockLog('some log');
const user = userEvent.setup();
const onViewFullLog = vi.fn();
renderWithProviders(<ProgressTab task={makeTask()} onViewFullLog={onViewFullLog} />);
await user.click(screen.getByRole('button'));
expect(onViewFullLog).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
/**
* Component tests for SubtasksPanel — the sub-run progress panel shown on the
* Overview tab. Verifies the header/progress fraction renders, the progress bar
* width reflects completed/total, each subtask card shows its status badge +
* title, and that the per-subtask activity/file queries stay dormant until a
* card is expanded (lazy: enabled only when expanded).
*/
import '../../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../test/render-helpers';
import '../../../i18n';
import type { SubtaskInfo } from '../../../api';
import * as api from '../../../api';
import { SubtasksPanel } from './SubtasksPanel';
// Card expansion fires these; mock so an expand never hits the network.
vi.mock('../../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api')>();
return {
...actual,
fetchSubtaskActivity: vi.fn().mockResolvedValue(''),
fetchSubtaskFiles: vi.fn().mockResolvedValue({ categories: {} }),
};
});
function subtask(overrides: Partial<SubtaskInfo> = {}): SubtaskInfo {
return {
id: 'job-sub-1',
issueNumber: 7,
status: 'succeeded',
instruction: 'Summarize chapter one\nthen extract the key points',
worktreePath: null,
createdAt: '2026-06-01T00:00:00Z',
updatedAt: '2026-06-01T00:00:00Z',
...overrides,
};
}
describe('SubtasksPanel', () => {
it('renders the header with the completed/total fraction', () => {
renderWithProviders(
<SubtasksPanel
taskId={1}
subtasks={[subtask()]}
subtaskCount={2}
subtaskCompleted={1}
/>,
);
expect(screen.getByText('Sub-runs')).toBeInTheDocument();
expect(screen.getByText(/1\/2/)).toBeInTheDocument();
});
it('reflects progress as a percentage width on the bar', () => {
const { container } = renderWithProviders(
<SubtasksPanel taskId={1} subtasks={[subtask()]} subtaskCount={4} subtaskCompleted={1} />,
);
// 1/4 = 25%.
const bar = container.querySelector('[style*="width: 25%"]');
expect(bar).not.toBeNull();
});
it('renders a card per subtask with status badge and first-line title', () => {
renderWithProviders(
<SubtasksPanel
taskId={1}
subtasks={[subtask({ status: 'failed', issueNumber: 9 })]}
subtaskCount={1}
subtaskCompleted={0}
/>,
);
// Status badge label (Failed).
expect(screen.getByText('Failed')).toBeInTheDocument();
// Title = issue number + first line of instruction.
expect(screen.getByText(/#9 Summarize chapter one/)).toBeInTheDocument();
});
it('does not fetch subtask activity/files until a card is expanded', async () => {
const user = userEvent.setup();
renderWithProviders(
<SubtasksPanel taskId={5} subtasks={[subtask()]} subtaskCount={1} subtaskCompleted={1} />,
);
// Collapsed: no lazy fetches yet.
expect(api.fetchSubtaskFiles).not.toHaveBeenCalled();
// Expanding the card enables the file query.
await user.click(screen.getByText(/#7 Summarize chapter one/));
expect(api.fetchSubtaskFiles).toHaveBeenCalled();
});
});
@@ -3,12 +3,13 @@ import { useTranslation } from 'react-i18next';
import i18n from '../../../i18n';
import { useQuery } from '@tanstack/react-query';
import { POLLING } from '../../../lib/constants.js';
import { SubtaskInfo, SubtaskActivity, SubtaskFiles, fetchSubtaskFiles, subtaskFileRawUrl, fetchSubtaskActivity } from '../../../api';
import { SubtaskInfo, SubtaskActivity } from '../../../api';
import { statusTone, formatStatusLabel, parseActivityLog, isPreviewable } from '../../../lib/utils';
import { ActivityTimeline } from '../../activity/ActivityTimeline';
import { LinkifiedText } from '../../../lib/linkified-text';
import { OutputPreviewProvider } from '../../../lib/output-preview-context';
import { stripOutputPrefix } from '../../../lib/output-path-detect';
import { useTaskDataSource, type TaskDataSource } from '../task-data-source';
export type SubtaskFilePreviewHandler = (taskId: number, jobId: string, category: string, filePath: string) => void;
@@ -38,7 +39,7 @@ const CATEGORY_LABELS: Record<string, string> = {
const CATEGORY_ORDER = ['output', 'logs', 'input'];
function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: number; jobId: string; category: string; files: string[]; onFilePreview?: SubtaskFilePreviewHandler }) {
function FileList({ taskId, jobId, category, files, onFilePreview, dataSource }: { taskId: number; jobId: string; category: string; files: string[]; onFilePreview?: SubtaskFilePreviewHandler; dataSource: TaskDataSource }) {
const label = i18n.t('detail:subtasks.category.' + category, { defaultValue: CATEGORY_LABELS[category] ?? category });
return (
<div className="mt-2">
@@ -57,7 +58,7 @@ function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: n
</button>
) : (
<a
href={subtaskFileRawUrl(taskId, jobId, `${category}/${filePath}`)}
href={dataSource.subtaskFileRawUrl(taskId, jobId, `${category}/${filePath}`)}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline break-all"
@@ -74,14 +75,16 @@ function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: n
}
function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardProps) {
const { t } = useTranslation('detail');
const [expanded, setExpanded] = useState(false);
const dataSource = useTaskDataSource();
const tone = statusTone(subtask.status);
const title = subtask.instruction.split('\n')[0]?.slice(0, 100) ?? '';
const isActive = ACTIVE_STATUSES.has(subtask.status);
const { data: activityLog } = useQuery({
queryKey: ['subtaskActivity', taskId, subtask.id],
queryFn: () => fetchSubtaskActivity(taskId, subtask.id),
queryKey: ['subtaskActivity', dataSource.readonly, taskId, subtask.id],
queryFn: () => dataSource.fetchSubtaskActivity(taskId, subtask.id),
refetchInterval: POLLING.FAST,
enabled: expanded && isActive,
});
@@ -90,8 +93,8 @@ function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardPr
const activityEvents = expanded ? parseActivityLog(displayLog) : [];
const { data: subtaskFiles, isLoading: filesLoading } = useQuery({
queryKey: ['subtask-files', taskId, subtask.id],
queryFn: () => fetchSubtaskFiles(taskId, subtask.id),
queryKey: ['subtask-files', dataSource.readonly, taskId, subtask.id],
queryFn: () => dataSource.fetchSubtaskFiles(taskId, subtask.id),
enabled: expanded,
refetchInterval: isActive ? POLLING.MEDIUM : false,
});
@@ -166,7 +169,7 @@ function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardPr
<div className="text-2xs font-semibold text-slate-500 mb-1">{t('subtasks.files')}</div>
{CATEGORY_ORDER.map(cat =>
categories[cat] && categories[cat].length > 0 ? (
<FileList key={cat} taskId={taskId} jobId={subtask.id} category={cat} files={categories[cat]} onFilePreview={onFilePreview} />
<FileList key={cat} taskId={taskId} jobId={subtask.id} category={cat} files={categories[cat]} onFilePreview={onFilePreview} dataSource={dataSource} />
) : null
)}
</div>
@@ -1,37 +0,0 @@
import { LocalTaskComment } from '../../../api';
import { useTranslation } from 'react-i18next';
import { MarkdownText } from '../../../lib/markdown-text';
// Comment kinds rendered here:
// `request` / `comment` (user), `progress` / `result` / `ask` (agent),
// `handoff` (system marker for /continue) → rendered as a horizontal
// divider instead of a card.
export function TimelineTab({ comments }: { comments: LocalTaskComment[] }) {
const { t } = useTranslation('detail');
return (
<div className="flex flex-col gap-2">
{comments.map(c => {
if (c.kind === 'handoff') {
return (
<div key={c.id} className="flex items-center gap-2 my-2">
<div className="flex-1 border-t border-slate-300" />
<div className="text-2xs text-slate-500 font-medium px-2 whitespace-nowrap">{c.body}</div>
<div className="flex-1 border-t border-slate-300" />
</div>
);
}
return (
<div key={c.id} className="bg-canvas border border-slate-200 rounded-xl p-3 shadow-sm">
<div className="flex justify-between items-center mb-1.5">
<div className="text-xs font-bold text-slate-700">{c.author}</div>
<div className="text-2xs text-slate-400">{new Date(c.createdAt).toLocaleString()}</div>
</div>
<div className="text-2xs text-slate-400 mb-1.5">{c.kind}</div>
<MarkdownText text={c.body} />
</div>
);
})}
{comments.length === 0 && <div className="text-[13px] text-slate-500">{t('timeline.empty')}</div>}
</div>
);
}
+2 -83
View File
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { fetchLocalFileContent } from '../../../api';
import { summarizeTraceEvent } from '../../../lib/traceEvent';
// Mirror of `src/progress/event-log.ts` EventBase. Kept as a duplicate
// here because the Vite UI build is a separate project from the engine.
@@ -106,89 +107,7 @@ function categoryFor(kind: string): string {
}
function summarizePayload(event: TraceEvent): string {
const p = event.payload as Record<string, unknown> | null;
if (!p) return '';
switch (event.kind) {
case 'tool_call': {
const args = p.args as Record<string, unknown> | undefined;
const filePath = args?.['file_path'] ?? args?.['path'] ?? args?.['url'] ?? args?.['pattern'];
return `${String(p.tool ?? '?')}${filePath ? ` ${filePath}` : ''}`;
}
case 'tool_result':
return `${String(p.tool ?? '?')} ${p.isError ? '⚠ error' : 'ok'}${p.cacheHit ? ' (cached)' : ''} ${formatDurationLabel(Number(p.durationMs ?? 0))}`;
case 'llm_call_start':
return `iter=${p.iteration ?? '?'} msgs=${p.messageCount ?? '?'}`;
case 'llm_call_end': {
const tokens = (typeof p.promptTokens === 'number' && typeof p.completionTokens === 'number')
? ` in=${p.promptTokens} out=${p.completionTokens}`
: '';
const shape = (p.toolCalls as number) > 0 ? ` tools=${p.toolCalls}`
: (p.textChars as number) > 0 ? ` text=${p.textChars}c`
: '';
return `${formatDurationLabel(Number(p.durationMs ?? 0))}${tokens}${shape}${p.hadError ? ' ⚠' : ''}`;
}
case 'cache_set':
return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`;
case 'cache_hit':
return `${String(p.tool ?? '?')} from ${String(p.sourceMovement ?? '?')} (${p.ageMs ?? '?'}ms ago)`;
case 'cache_invalidate':
case 'memory_invalidate':
return `${String(p.trigger ?? '')}${p.entriesEvicted ?? 0} entries`;
case 'memory_update_call': {
const counts = p.counts as Record<string, number> | null;
if (!counts) return p.empty ? 'empty payload' : '';
const parts: string[] = [];
if (counts.factsAdded) parts.push(`facts +${counts.factsAdded}`);
if (counts.factsMerged) parts.push(`facts merged ${counts.factsMerged}`);
if (counts.decisionsAdded) parts.push(`decisions +${counts.decisionsAdded}`);
if (counts.openQuestionsAdded) parts.push(`open_questions +${counts.openQuestionsAdded}`);
if (counts.doNotRepeatAdded) parts.push(`do_not_repeat +${counts.doNotRepeatAdded}`);
return parts.join(', ') || 'no changes';
}
case 'memory_handoff_write':
return p.skipped ? `skipped: ${p.reason}` : `→ child #${p.subtaskIndex ?? '?'} (${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`;
case 'memory_handoff_read':
return `from parent ${String(p.parentJobId ?? '?')}`;
case 'memory_delta_write':
return p.skipped ? `skipped: ${p.reason}` : `${p.childStatus} ${p.partial ? '(partial) ' : ''}(${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`;
case 'memory_delta_absorb':
return `${String(p.outcome ?? '?')}${p.childJobId ? `${p.childJobId}` : ''}`;
case 'memory_snapshot_written': {
const parts: string[] = [];
if (typeof p.facts === 'number') parts.push(`${p.facts}f`);
if (typeof p.decisions === 'number') parts.push(`${p.decisions}d`);
if (typeof p.openQuestions === 'number') parts.push(`${p.openQuestions}q`);
const counts = parts.length ? ` (${parts.join('/')})` : '';
const sizeKb = typeof p.bytes === 'number' ? ` ${(p.bytes / 1024).toFixed(1)}KB` : '';
return `${String(p.status ?? '?')}${String(p.path ?? '?')}${counts}${sizeKb}`;
}
case 'memory_snapshot_failed':
return `${String(p.status ?? '?')} write failed: ${String(p.error ?? '?')}`;
case 'watchdog_fire':
return `${String(p.kind2 ?? '')} at iter=${p.iteration ?? '?'}`;
case 'followup_detected':
return `movement=${String(p.movementName ?? '?')}`;
case 'context_action':
return `${String(p.type ?? '?')} ratio=${typeof p.ratio === 'number' ? (p.ratio * 100).toFixed(0) + '%' : '?'}`;
case 'transition':
return `${String(p.nextStep ?? '?')}`;
case 'complete':
return `${String(p.status ?? '?')}`;
case 'movement_start':
return `visit ${p.visitCount ?? '?'}/${p.maxVisits ?? '?'}`;
case 'movement_complete':
return `${String(p.next ?? '?')}`;
case 'run_start':
return `piece=${String(p.pieceName ?? '?')}`;
case 'run_complete': {
const cancel = p.cancel as { phase?: string; movement?: string } | undefined;
const cancelInfo = cancel?.phase ? ` cancel:${cancel.phase}@${cancel.movement ?? '?'}` : '';
const snapshot = p.memorySnapshotPath ? ` snapshot:${String(p.memorySnapshotPath).replace(/^logs\//, '')}` : '';
return `${String(p.status ?? '?')}${p.abortReason ? ` (${p.abortReason})` : ''}${cancelInfo}${snapshot}`;
}
default:
return '';
}
return summarizeTraceEvent(event);
}
interface TraceTabProps {
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
createAuthedTaskDataSource,
createSharedTaskDataSource,
} from './task-data-source';
// 純ロジックのみ検証(DOM/hook 非依存)。
// - authed 実装は readonly=false で /api/local/... を選ぶ
// - shared 実装は readonly=true で /api/shared/:token/... を選ぶ
// - Provider 未設置時に useTaskDataSource() が authed 既定へ落ちることは
// default 値(createAuthedTaskDataSource)で担保する(本体後方互換)。
describe('TaskDataSource', () => {
describe('authed implementation', () => {
const ds = createAuthedTaskDataSource();
it('is not read-only', () => {
expect(ds.readonly).toBe(false);
});
it('builds local subtask raw file URLs', () => {
expect(ds.subtaskFileRawUrl(7, 'job-1', 'output/a.png')).toBe(
'/api/local/tasks/7/subtasks/job-1/files/output/a.png',
);
});
});
describe('shared implementation', () => {
const ds = createSharedTaskDataSource('tok123');
it('is read-only', () => {
expect(ds.readonly).toBe(true);
});
it('builds shared subtask raw file URLs scoped to the token', () => {
expect(ds.subtaskFileRawUrl(7, 'job-1', 'output/a.png')).toBe(
'/api/shared/tok123/subtasks/job-1/files/output/a.png',
);
});
it('encodes the token in the raw URL', () => {
const scoped = createSharedTaskDataSource('a/b');
expect(scoped.subtaskFileRawUrl(1, 'j', 'x.txt')).toBe(
'/api/shared/a%2Fb/subtasks/j/files/x.txt',
);
});
});
});
@@ -0,0 +1,75 @@
import { createContext, useContext, type ReactNode } from 'react';
import {
fetchSubtaskActivity,
fetchSubtaskFiles,
subtaskFileRawUrl,
fetchSharedSubtaskActivity,
fetchSharedSubtaskFiles,
sharedSubtaskFileRawUrl,
type SubtaskFiles,
} from '../../api';
/**
* TaskDataSource — タスク詳細パネルが必要とするデータ取得を抽象化する。
*
* 本体(認証版)は `/api/local/...` を叩き readonly=false。共有ページは
* トークンベースの `/api/shared/:token/...` を叩き readonly=true。
* これにより `LocalDetailPanel` / `SubtasksPanel` を共有ページで read-only
* 再利用でき、独自実装のドリフトを防ぐ。
*
* 重要(後方互換): Provider 未設置時、useTaskDataSource() は authed 既定を
* 返す。本体は Provider を新たに必須化しない。
*/
export interface TaskDataSource {
/** 編集系 UI を隠すかどうか。authed=false / shared=true。 */
readonly: boolean;
/** 個別サブタスクの activity.log(全文)を取得する。 */
fetchSubtaskActivity(taskId: number, jobId: string): Promise<string>;
/** 個別サブタスクのファイル一覧(output/logs/input)を取得する。 */
fetchSubtaskFiles(taskId: number, jobId: string): Promise<SubtaskFiles>;
/** サブタスクのファイルへの直接 raw URL`category/relPath` 形式)。 */
subtaskFileRawUrl(taskId: number, jobId: string, filePath: string): string;
}
/** 認証版(本体既定)。`/api/local/...` を使い readonly=false。 */
export function createAuthedTaskDataSource(): TaskDataSource {
return {
readonly: false,
fetchSubtaskActivity,
fetchSubtaskFiles,
subtaskFileRawUrl,
};
}
/** 共有版(read-only)。`/api/shared/:token/...` を使い readonly=true。 */
export function createSharedTaskDataSource(token: string): TaskDataSource {
return {
readonly: true,
fetchSubtaskActivity: (_taskId: number, jobId: string): Promise<string> =>
fetchSharedSubtaskActivity(token, jobId),
fetchSubtaskFiles: (_taskId: number, jobId: string): Promise<SubtaskFiles> =>
fetchSharedSubtaskFiles(token, jobId),
subtaskFileRawUrl: (_taskId: number, jobId: string, filePath: string): string =>
sharedSubtaskFileRawUrl(token, jobId, filePath),
};
}
// Provider 未設置時の既定 = authed。本体(Tasks ページ / Space チャット)は
// この既定にそのまま乗るため、Provider を増設しなくても従来挙動を保つ。
const TaskDataSourceContext = createContext<TaskDataSource>(createAuthedTaskDataSource());
export function TaskDataSourceProvider({
value,
children,
}: {
value: TaskDataSource;
children: ReactNode;
}) {
return (
<TaskDataSourceContext.Provider value={value}>{children}</TaskDataSourceContext.Provider>
);
}
export function useTaskDataSource(): TaskDataSource {
return useContext(TaskDataSourceContext);
}
+40 -5
View File
@@ -2,6 +2,10 @@
* FileBreadcrumb — ファイルブラウザのパンくず(ルート / seg / seg …)。
* スペースのファイルタブとタスクのファイルタブで共有する。
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DND_FILES_MIME, readDragSources } from '../../lib/fileDnd';
interface FileBreadcrumbProps {
/** 現在パスのセグメント配列(空配列 = ルート)。 */
pathSegments: string[];
@@ -9,23 +13,53 @@ interface FileBreadcrumbProps {
onNavigate: (path: string) => void;
/** ルートラベル左に出す任意のプレフィックス(例: '/files')。 */
testid?: string;
/**
* Phase 2: パンくずの祖先セグメント(やルート)へドラッグ&ドロップしたときの移動。
* 指定すると祖先がドロップ先になり、上の階層へファイルを移せる。
*/
onMoveDrop?: (sourcePaths: string[], destDir: string) => void;
}
export function FileBreadcrumb({ pathSegments, onNavigate, testid }: FileBreadcrumbProps) {
export function FileBreadcrumb({ pathSegments, onNavigate, testid, onMoveDrop }: FileBreadcrumbProps) {
const { t } = useTranslation('files');
const [dropPath, setDropPath] = useState<string | null>(null);
// ドロップ先になれるのは「現在地ではない」祖先のみ(現在地=最後のセグメント)。
const dropProps = (path: string, isCurrent: boolean) =>
onMoveDrop && !isCurrent
? {
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(DND_FILES_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move' as const;
if (dropPath !== path) setDropPath(path);
},
onDragLeave: () => setDropPath(p => (p === path ? null : p)),
onDrop: (e: React.DragEvent) => {
e.preventDefault();
setDropPath(null);
const sources = readDragSources(e.dataTransfer);
if (sources.length) onMoveDrop(sources, path);
},
'data-drop-target': 'true' as const,
}
: {};
const dropRing = (path: string) =>
dropPath === path ? ' ring-2 ring-[var(--brand-primary)] text-slate-900' : '';
return (
<nav
data-testid={testid}
aria-label="パンくず"
aria-label={t('breadcrumb.nav')}
className="flex flex-wrap items-center gap-0.5 text-2xs text-slate-500"
>
<button
type="button"
onClick={() => onNavigate('')}
className="rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700"
{...dropProps('', pathSegments.length === 0)}
className={`rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700${dropRing('')}`}
disabled={pathSegments.length === 0}
aria-current={pathSegments.length === 0 ? 'location' : undefined}
>
{t('breadcrumb.root')}
</button>
{pathSegments.map((seg, i) => {
const prefix = pathSegments.slice(0, i + 1).join('/');
@@ -36,7 +70,8 @@ export function FileBreadcrumb({ pathSegments, onNavigate, testid }: FileBreadcr
<button
type="button"
onClick={() => onNavigate(prefix)}
className="max-w-[14ch] truncate rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700"
{...dropProps(prefix, isLast)}
className={`max-w-[14ch] truncate rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700${dropRing(prefix)}`}
disabled={isLast}
aria-current={isLast ? 'location' : undefined}
title={seg}
+92 -143
View File
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTileGrid } from './FileTileGrid';
import { FileActions, FileSelectionBar, FileDropzone } from './FileToolbar';
import { FileDetailList } from './FileDetailList';
import { FileActions, FileSelectionBar, FileDropzone, FileViewToggle, FileSortMenu, type FileSort } from './FileToolbar';
import { useFileView } from '../../hooks/useFileView';
import { workspaceDirRole } from '../../lib/workspaceDirs';
/**
* タスク詳細のファイルタブで使う書込(アップロード/削除)操作一式。
@@ -39,121 +41,20 @@ interface FileBrowserProps {
onRefresh?: () => void;
isRefreshing?: boolean;
management?: FileManagement;
/**
* ファイルのダウンロード/生 URL を組み立てる任意の上書き関数。
* 指定すると本体既定の `getLocalFileRawUrl(taskId, section, path)` の代わりに使う。
* 共有ビュー(`/api/shared/:token/files/raw`)のように taskId/section を持たない
* 文脈で raw URL を差し替えるために足した後方互換 prop。未指定なら従来動作。
*/
rawUrlFor?: (entry: LocalFileEntry) => string;
/**
* 区分タブ(workspace/input/output/logs)を隠すか。既定 false。
* 共有ビューは output のみを配信するため true にして区分タブを出さない。
*/
hideSections?: boolean;
}
type FileSort = 'name' | 'newest';
const SORT_OPTIONS: Array<{ value: FileSort; labelKey: string }> = [
{ value: 'name', labelKey: 'sort.name' },
{ value: 'newest', labelKey: 'sort.newest' },
];
function FileSortMenu({ sort, onChange }: { sort: FileSort; onChange: (s: FileSort) => void }) {
const { t } = useTranslation('files');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
const current = SORT_OPTIONS.find(o => o.value === sort) ?? SORT_OPTIONS[0];
const handleSelect = (value: FileSort) => {
onChange(value);
setOpen(false);
triggerRef.current?.focus();
};
return (
<div ref={containerRef} className="relative flex-shrink-0">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(v => !v)}
title={t('sort.tooltip', { mode: t(current.labelKey) })}
aria-haspopup="true"
aria-expanded={open}
aria-label={t('sort.tooltip', { mode: t(current.labelKey) })}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
open ? 'bg-accent-soft text-accent' : 'text-slate-500 hover:text-slate-900 hover:bg-surface-2'
}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+6px)] z-10 bg-canvas border border-hairline rounded-md shadow min-w-[140px] p-1">
{SORT_OPTIONS.map(o => {
const selected = sort === o.value;
return (
<button
key={o.value}
type="button"
onClick={() => handleSelect(o.value)}
className={`flex items-center justify-between w-full px-2.5 py-1.5 rounded text-xs text-left transition-colors ${
selected
? 'bg-accent-soft text-accent font-semibold'
: 'text-slate-700 font-medium hover:bg-surface-2'
}`}
>
{t(o.labelKey)}
{selected && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
)}
</div>
);
}
function sortEntries(entries: LocalFileEntry[], mode: FileSort): LocalFileEntry[] {
const dirs = entries.filter(e => e.kind === 'directory');
const files = entries.filter(e => e.kind !== 'directory');
const sortFn = mode === 'newest'
? (a: LocalFileEntry, b: LocalFileEntry) => {
const at = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
const bt = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
if (at !== bt) return bt - at;
return a.name.localeCompare(b.name);
}
: (a: LocalFileEntry, b: LocalFileEntry) => a.name.localeCompare(b.name);
return [...dirs.sort(sortFn), ...files.sort(sortFn)];
}
export function FileBrowser({
section,
@@ -167,23 +68,36 @@ export function FileBrowser({
onRefresh,
isRefreshing,
management,
rawUrlFor,
hideSections,
}: FileBrowserProps) {
const { t } = useTranslation('files');
const SECTIONS = ['workspace', 'input', 'output', 'logs'] as const;
const [sort, setSort] = useState<FileSort>('name');
const sortedEntries = useMemo(() => sortEntries(entries, sort), [entries, sort]);
const { viewMode, setViewMode, sort, setSort, toggleSort, sortedEntries } = useFileView(entries);
// アイコン表示のドロップダウン(名前順/新しい順)は共有ソート状態の上に薄く乗せる。
// サイズ順は詳細表示の列見出しから操作する。
const menuSort: FileSort = sort.key === 'modified' ? 'newest' : 'name';
const onMenuSort = (s: FileSort) =>
setSort(s === 'newest' ? { key: 'modified', dir: 'desc' } : { key: 'name', dir: 'asc' });
// 書込ツールバーを出す条件: management があり、owner/admin かつ書込可能区分。
const canWrite = !!management && management.canManage && management.writableSection;
const filePaths = sortedEntries.filter(e => e.kind !== 'directory').map(e => e.path);
const selectedInView = management ? filePaths.filter(p => management.selected.has(p)) : [];
const allSelected = filePaths.length > 0 && selectedInView.length === filePaths.length;
// raw/ダウンロード URL ビルダー。明示の rawUrlFor を最優先し、
// 無ければ taskId がある場合に限り本体既定(local raw URL)を使う。
const fileHref = rawUrlFor ?? (taskId != null ? ((entry: LocalFileEntry) => getLocalFileRawUrl(taskId, section, entry.path)) : undefined);
// 選択・一括削除/DL の対象: ファイル+ユーザー作成フォルダ(構造フォルダ=workspaceDirRole 有
// は足場なので除外)。フォルダ単体の zip DL は各行の onDownloadDir で扱う。
const selectablePaths = sortedEntries
.filter(e => e.kind !== 'directory' || !workspaceDirRole(e.path, e.name, e.kind))
.map(e => e.path);
const selectedInView = management ? selectablePaths.filter(p => management.selected.has(p)) : [];
const allSelected = selectablePaths.length > 0 && selectedInView.length === selectablePaths.length;
return (
<div className="flex flex-col gap-3">
{/* 区分タブ + アクション(追加 / 再読み込み) */}
<div className="flex gap-1 flex-wrap items-center">
{SECTIONS.map(s => (
{!hideSections && SECTIONS.map(s => (
<button
key={s}
onClick={() => { onSectionChange(s); onNavigate(''); }}
@@ -208,21 +122,34 @@ export function FileBrowser({
</div>
</div>
{/* パス表示 + 並び替え */}
{/* パンくず(現在地)+ 並び替え・表示切替。パスはパンくずのみで表す(テキスト二重表示を廃止)。 */}
<div className="flex items-start justify-between gap-2">
<div className="text-2xs text-slate-500 font-mono break-all min-w-0 flex-1 pt-1">
/{section}{currentPath ? `/${currentPath}` : ''}
<div className="min-w-0 flex-1 pt-0.5">
<FileBreadcrumb testid="task-files-breadcrumb" pathSegments={pathSegments} onNavigate={onNavigate} />
</div>
<div className="flex shrink-0 items-center gap-1.5">
{viewMode === 'icon' && <FileSortMenu sort={menuSort} onChange={onMenuSort} />}
<FileViewToggle idPrefix="task" mode={viewMode} onChange={setViewMode} />
</div>
<FileSortMenu sort={sort} onChange={setSort} />
</div>
<FileBreadcrumb testid="task-files-breadcrumb" pathSegments={pathSegments} onNavigate={onNavigate} />
{/* step4「成果物の在りか」の案内。output 区分の最上位がまだ空のときだけ、
エージェントの成果物がここに溜まることを控えめに示す(チャット空状態の
案内と同じ文言・トーンに合わせる)。ファイルが出来たら自然に消える。 */}
{section === 'output' && currentPath === '' && entries.length === 0 && (
<p
data-testid="output-empty-hint"
className="rounded-md border border-hairline bg-surface px-3 py-2 text-2xs text-slate-400 leading-relaxed"
>
{t('browser.outputHint')}
</p>
)}
{canWrite && filePaths.length > 0 && (
{canWrite && selectablePaths.length > 0 && (
<FileSelectionBar
idPrefix="task"
allSelected={allSelected}
onToggleSelectAll={() => management?.toggleSelectAll(filePaths)}
onToggleSelectAll={() => management?.toggleSelectAll(selectablePaths)}
selectedCount={selectedInView.length}
onDeleteSelected={() => management?.remove(selectedInView)}
onDownloadSelected={() => management?.download(selectedInView)}
@@ -243,21 +170,43 @@ export function FileBrowser({
isUploading={management?.isUploading}
onDropFiles={files => management?.upload(files)}
>
<FileTileGrid
entries={sortedEntries}
idPrefix="task"
canManage={canWrite}
selected={management?.selected ?? new Set()}
onToggleSelect={path => management?.toggleSelect(path)}
onOpenDir={onNavigate}
onOpenFile={onPreview}
onDeleteOne={path => management?.remove([path])}
isDeleting={management?.isDeleting}
fileHref={taskId != null ? (entry => getLocalFileRawUrl(taskId, section, entry.path)) : undefined}
emptyHint={canWrite
? 'ファイルがありません。ここにドラッグ&ドロップ、または「+ 追加」で追加できます。'
: t('empty')}
/>
{viewMode === 'detail' ? (
<FileDetailList
entries={sortedEntries}
idPrefix="task"
canManage={canWrite}
selected={management?.selected ?? new Set()}
onToggleSelect={path => management?.toggleSelect(path)}
onOpenDir={onNavigate}
onOpenFile={onPreview}
onDeleteOne={path => management?.remove([path])}
isDeleting={management?.isDeleting}
fileHref={fileHref}
onDownloadDir={management ? (path => management.download([path])) : undefined}
sort={sort}
onSort={toggleSort}
emptyHint={canWrite
? t('browser.emptyManage')
: t('empty')}
/>
) : (
<FileTileGrid
entries={sortedEntries}
idPrefix="task"
canManage={canWrite}
selected={management?.selected ?? new Set()}
onToggleSelect={path => management?.toggleSelect(path)}
onOpenDir={onNavigate}
onOpenFile={onPreview}
onDeleteOne={path => management?.remove([path])}
isDeleting={management?.isDeleting}
fileHref={fileHref}
onDownloadDir={management ? (path => management.download([path])) : undefined}
emptyHint={canWrite
? t('browser.emptyManage')
: t('empty')}
/>
)}
</FileDropzone>
</div>
);
+282
View File
@@ -0,0 +1,282 @@
/**
* FileDetailList — ファイル/ディレクトリの詳細(テーブル)表示。Windows エクスプローラーの
* 「詳細表示」に相当し、名前・更新日時・サイズを一覧で見せる。
*
* FileTileGrid と同じ操作プロップ(open/select/delete/download)を受ける純表示部品。
* 並べ替えは列見出しクリックで行い、状態は呼出側(useFileView)が持つ。
*
* testid は idPrefix から組み立てる('task' → task-files-list / task-file-row …)。
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalFileEntry } from '../../api';
import { FileTypeIcon } from './FileTypeIcon';
import { formatFileSize, formatFileTimestamp, type FileSortKey, type FileSortState } from '../../lib/fileView';
import { workspaceDirRole } from '../../lib/workspaceDirs';
import { DND_FILES_MIME, dragSources, readDragSources } from '../../lib/fileDnd';
interface FileDetailListProps {
entries: LocalFileEntry[];
idPrefix: string;
canManage: boolean;
selected: Set<string>;
onToggleSelect: (path: string) => void;
onOpenDir: (path: string) => void;
onOpenFile: (path: string, name: string) => void;
onDeleteOne: (path: string) => void;
isDeleting?: boolean;
fileHref?: (entry: LocalFileEntry) => string;
emptyHint?: React.ReactNode;
/** 現在の並べ替え状態(見出しの ▲▼ 表示に使う)。 */
sort: FileSortState;
/** 列見出しクリック。 */
onSort: (key: FileSortKey) => void;
/**
* 行末の操作列に差し込む任意アクション(例: スペースの「アプリとして実行」)。
* アイコン表示の renderTileOverlay と対になる詳細表示版。
*/
renderRowAction?: (entry: LocalFileEntry) => React.ReactNode;
/**
* Phase 2: ドラッグ移動。指定すると(かつ canManage が true なら)行をドラッグして
* フォルダ行へドロップで移動できる。sourcePaths は選択を考慮した移動元集合。
*/
onMoveDrop?: (sourcePaths: string[], destDir: string) => void;
/** フォルダの zip ダウンロード。指定するとフォルダ行に zip DL ボタンを出す(構造フォルダ含む全フォルダ)。 */
onDownloadDir?: (path: string) => void;
}
const HEADERS: Array<{ key: FileSortKey; labelKey: string; align: 'left' | 'right'; className: string }> = [
{ key: 'name', labelKey: 'detail.header.name', align: 'left', className: '' },
{ key: 'modified', labelKey: 'detail.header.modified', align: 'left', className: 'w-36 whitespace-nowrap' },
{ key: 'size', labelKey: 'detail.header.size', align: 'right', className: 'w-24 whitespace-nowrap' },
];
function SortArrow({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
if (!active) return null;
return <span className="ml-1 text-[9px] text-slate-400" aria-hidden>{dir === 'asc' ? '▲' : '▼'}</span>;
}
export function FileDetailList({
entries,
idPrefix,
canManage,
selected,
onToggleSelect,
onOpenDir,
onOpenFile,
onDeleteOne,
isDeleting,
fileHref,
emptyHint,
sort,
onSort,
renderRowAction,
onMoveDrop,
onDownloadDir,
}: FileDetailListProps) {
const { t } = useTranslation('files');
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
const dndEnabled = canManage && !!onMoveDrop;
return (
<div className="overflow-x-auto">
<table data-testid={`${idPrefix}-files-list`} className="w-full min-w-[420px] border-collapse text-xs">
<thead>
<tr className="border-b border-hairline text-slate-500">
{canManage && <th className="w-7 px-1 py-1.5" />}
{HEADERS.map((h) => {
const label = t(h.labelKey);
return (
<th
key={h.key}
aria-sort={sort.key === h.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}
className={`px-2 py-1.5 font-medium ${h.align === 'right' ? 'text-right' : 'text-left'} ${h.className}`}
>
<button
type="button"
data-testid={`${idPrefix}-files-sort-${h.key}`}
onClick={() => onSort(h.key)}
className={`inline-flex items-center hover:text-slate-800 ${h.align === 'right' ? 'flex-row-reverse' : ''}`}
title={t('detail.sortBy', { label })}
>
{label}
<SortArrow active={sort.key === h.key} dir={sort.dir} />
</button>
</th>
);
})}
{/* 行操作(ダウンロード/削除)用の余白列 */}
<th className="w-16 px-1 py-1.5" />
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const isFile = entry.kind !== 'directory';
// 構造フォルダ(input/output/logs/apps/readonly 等 = role 有)は足場なので
// 選択・削除・移動の対象外。ダウンロードは全フォルダ可(下の onDownloadDir)。
const dirRole = workspaceDirRole(entry.path, entry.name, entry.kind);
const canSelectDelete = canManage && (isFile || !dirRole);
const isChecked = canSelectDelete && selected.has(entry.path);
const isDropTarget = dndEnabled && entry.kind === 'directory';
const isDragOver = isDropTarget && dragOverPath === entry.path;
const canDrag = dndEnabled && !dirRole;
return (
<tr
key={`${entry.kind}:${entry.path}`}
data-testid={`${idPrefix}-file-row`}
data-kind={entry.kind}
data-name={entry.name}
draggable={canDrag || undefined}
onDragStart={canDrag ? e => {
e.dataTransfer.setData(DND_FILES_MIME, JSON.stringify(dragSources(entry, selected)));
e.dataTransfer.effectAllowed = 'move';
} : undefined}
onDragOver={isDropTarget ? e => {
if (!e.dataTransfer.types.includes(DND_FILES_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverPath !== entry.path) setDragOverPath(entry.path);
} : undefined}
onDragLeave={isDropTarget ? () => setDragOverPath(p => (p === entry.path ? null : p)) : undefined}
onDrop={isDropTarget ? e => {
e.preventDefault();
setDragOverPath(null);
const sources = readDragSources(e.dataTransfer);
if (sources.length) onMoveDrop!(sources, entry.path);
} : undefined}
data-drop-target={isDropTarget ? 'true' : undefined}
className={`group border-b border-hairline/60 hover:bg-surface ${
isDragOver ? 'ring-2 ring-inset ring-[var(--brand-primary)] bg-surface' : isChecked ? 'bg-surface' : ''
}`}
>
{canManage && (
<td className="px-1 py-1 align-middle">
{canSelectDelete && (
<input
type="checkbox"
data-testid={`${idPrefix}-file-select-${entry.name}`}
checked={isChecked}
onChange={() => onToggleSelect(entry.path)}
aria-label={t('detail.selectAria', { name: entry.name })}
className="h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)]"
/>
)}
</td>
)}
<td className="px-2 py-1 align-middle">
<button
type="button"
data-testid={`${idPrefix}-file-tile`}
data-kind={entry.kind}
data-name={entry.name}
onClick={() => (entry.kind === 'directory' ? onOpenDir(entry.path) : onOpenFile(entry.path, entry.name))}
title={entry.name}
className="flex min-w-0 items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 rounded"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-400" aria-hidden>
{entry.kind === 'directory' ? (
<svg className="h-5 w-5 text-amber-400" viewBox="0 0 16 16" fill="currentColor" stroke="none">
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
</svg>
) : (
<FileTypeIcon name={entry.name} className="h-5 w-5" />
)}
</span>
<span className="truncate text-slate-700">{entry.name}</span>
{(() => {
const role = workspaceDirRole(entry.path, entry.name, entry.kind);
if (!role) return null;
return (
<span
data-testid={`${idPrefix}-dir-badge-${entry.name}`}
data-writable={role.writable ? 'true' : 'false'}
title={role.title}
className={`shrink-0 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium ${role.className}`}
>
{!role.writable && (
<svg className="h-2.5 w-2.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
<rect x="3.5" y="7" width="9" height="6" rx="1" />
<path d="M5.5 7V5a2.5 2.5 0 015 0v2" />
</svg>
)}
{role.label}
</span>
);
})()}
</button>
</td>
<td className="px-2 py-1 align-middle whitespace-nowrap text-slate-500" title={entry.modifiedAt ?? ''}>
{formatFileTimestamp(entry.modifiedAt)}
</td>
<td className="px-2 py-1 align-middle whitespace-nowrap text-right tabular-nums text-slate-500">
{isFile ? formatFileSize(entry.size) : '—'}
</td>
<td className="px-1 py-1 align-middle">
{(() => {
// 行アクション(リネーム等)はファイル・フォルダ両方。ファイルは <a> 直 DL、
// フォルダは zip DLonDownloadDir)。削除はファイル+ユーザー作成フォルダ。
const rowAction = renderRowAction?.(entry);
const showDirDownload = entry.kind === 'directory' && !!onDownloadDir;
const showOps = (isFile && fileHref) || showDirDownload || canSelectDelete;
if (!rowAction && !showOps) return null;
return (
<div className="flex items-center justify-end gap-0.5">
{rowAction}
{isFile && fileHref && (
<a
href={fileHref(entry)}
download={entry.name}
onClick={(e) => e.stopPropagation()}
data-testid={`${idPrefix}-file-download-${entry.name}`}
title={t('detail.download')}
aria-label={t('detail.downloadAria', { name: entry.name })}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
</svg>
</a>
)}
{showDirDownload && (
<button
type="button"
data-testid={`${idPrefix}-dir-download-${entry.name}`}
onClick={(e) => { e.stopPropagation(); onDownloadDir!(entry.path); }}
title={t('detail.dirDownload')}
aria-label={t('detail.dirDownloadAria', { name: entry.name })}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
</svg>
</button>
)}
{canSelectDelete && (
<button
type="button"
data-testid={`${idPrefix}-file-delete-${entry.name}`}
onClick={() => onDeleteOne(entry.path)}
disabled={isDeleting}
title={entry.kind === 'directory' ? t('detail.deleteDir') : t('detail.delete')}
aria-label={t('detail.deleteAria', { name: entry.name })}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover disabled:opacity-50 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
</button>
)}
</div>
);
})()}
</td>
</tr>
);
})}
</tbody>
</table>
{entries.length === 0 && emptyHint && (
<div className="px-1 py-6 text-center text-xs text-slate-500">{emptyHint}</div>
)}
</div>
);
}
@@ -0,0 +1,126 @@
// @vitest-environment jsdom
/**
* Component-render tests for FilePreview's per-type body branches:
* - markdown → rendered HTML; a RELATIVE image href is rewritten to the
* workspace raw-file endpoint via resolvePreviewImageHref
* (the pure helper itself is tested in lib/filePreviewPath.test.ts;
* here we assert the COMPONENT actually wires it into the <img src>).
* - csv → an HTML <table> with one <td> per cell
* - jsonl → renders rows (does not crash on structured lines)
* - unknown → falls back to a <pre> with the raw content
*
* Heavy/irrelevant deps are mocked: mermaid (async diagram run), the office API,
* and EmbedBlock. i18n uses the real instance for the 'files' namespace.
*/
import '../../test/dom-setup';
import '../../i18n';
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { screen, within } from '@testing-library/react';
import { render } from '@testing-library/react';
// jsdom has no IntersectionObserver; MarkdownPreview's scroll-spy effect uses
// one when the doc has headings. Provide a no-op stub so the effect is inert.
beforeAll(() => {
if (!('IntersectionObserver' in globalThis)) {
class IO {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() { return []; }
}
// @ts-expect-error test stub
globalThis.IntersectionObserver = IO;
}
});
// mermaid.run is fire-and-forget inside a useEffect; stub it so jsdom does not
// choke on diagram rendering.
vi.mock('mermaid', () => ({
default: { initialize: vi.fn(), run: vi.fn().mockResolvedValue(undefined) },
}));
// EmbedBlock fetches structured blocks; the markdown we feed has no embeds, but
// stub it anyway to keep the module graph light and network-free.
vi.mock('../embed/EmbedBlock', () => ({ EmbedBlock: () => null }));
// Avoid pulling real network code from the office-preview path.
vi.mock('../../api', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api')>();
return {
...actual,
fetchOfficePreview: vi.fn(),
updateLocalFileContent: vi.fn(),
};
});
import { FilePreview } from './FilePreview';
const noop = () => {};
describe('FilePreview body branches', () => {
it('markdown: renders HTML and rewrites a relative image href to the raw endpoint', () => {
const base = '/api/local/tasks/42/files/raw?path=';
const md = '# Title\n\n![alt text](images/pic.png)\n';
render(
<FilePreview
name="report.md"
content={md}
markdownImageBaseUrl={base}
onClose={noop}
/>,
);
// heading rendered as HTML
expect(screen.getByRole('heading', { name: /Title/ })).toBeInTheDocument();
// the relative image is rewritten through resolvePreviewImageHref
const img = screen.getByAltText('alt text') as HTMLImageElement;
expect(img.getAttribute('src')).toBe(`${base}${encodeURIComponent('images/pic.png')}`);
});
it('markdown: leaves an absolute image href untouched', () => {
const md = '![remote](https://example.com/x.png)';
render(
<FilePreview
name="a.md"
content={md}
markdownImageBaseUrl="/api/local/tasks/1/files/raw?path="
onClose={noop}
/>,
);
const img = screen.getByAltText('remote') as HTMLImageElement;
expect(img.getAttribute('src')).toBe('https://example.com/x.png');
});
it('csv: renders a table with one cell per value', () => {
const csv = 'name,age\nalice,30\nbob,25';
render(<FilePreview name="data.csv" content={csv} onClose={noop} />);
const table = screen.getByRole('table');
const cells = within(table).getAllByRole('cell');
// 3 rows x 2 cols = 6 cells
expect(cells).toHaveLength(6);
expect(within(table).getByText('alice')).toBeInTheDocument();
expect(within(table).getByText('25')).toBeInTheDocument();
});
it('jsonl: renders structured rows without crashing', () => {
const jsonl = [
JSON.stringify({ tool: 'WebFetch', outcome: 'ok' }),
JSON.stringify({ tool: 'Bash', outcome: 'failed' }),
].join('\n');
render(<FilePreview name="log.jsonl" content={jsonl} onClose={noop} />);
// values from the jsonl appear somewhere in the rendered output
expect(screen.getByText(/WebFetch/)).toBeInTheDocument();
expect(screen.getByText(/Bash/)).toBeInTheDocument();
});
it('unknown type: falls back to a <pre> with the raw content', () => {
render(<FilePreview name="notes.xyz" content="just some plain text" onClose={noop} />);
expect(screen.getByText('just some plain text')).toBeInTheDocument();
expect(screen.getByText('just some plain text').tagName).toBe('PRE');
});
it('renders the filename in the header and wires the close button', () => {
render(<FilePreview name="report.md" content="# Hi" onClose={noop} />);
expect(screen.getByTitle('report.md')).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
+158 -8
View File
@@ -6,9 +6,11 @@ import { Marked, Renderer } from 'marked';
import DOMPurify from 'dompurify';
import mermaid from 'mermaid';
import hljs from 'highlight.js';
import { updateLocalFileContent } from '../../api';
import { updateLocalFileContent, fetchOfficePreview, OfficePreviewError } from '../../api';
import type { OfficePreview, OfficeSpreadsheetPreview, OfficePresentationPreview } from '../../api';
import { EmbedBlock } from '../embed/EmbedBlock';
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
import { resolvePreviewImageHref } from '../../lib/filePreviewPath';
import { useBackdropClose } from '../../lib/useBackdropClose';
mermaid.initialize({ startOnLoad: false, theme: 'default' });
@@ -66,6 +68,153 @@ interface FilePreviewProps {
filePath?: string;
editable?: boolean;
trustedHtmlUrl?: string;
/** Excel / PowerPoint プレビュー。設定されると office-preview API を取得して描画する。 */
office?: OfficePreviewDescriptor;
}
export interface OfficePreviewDescriptor {
kind: 'spreadsheet' | 'presentation';
/** office-preview エンドポイントの完全 URL */
url: string;
/** 失敗時のダウンロード用 raw URL (任意) */
downloadUrl?: string;
}
// --- Office (Excel / PowerPoint) ---
/** 0 始まりの列番号を Excel 風の列ラベル (A, B, ... Z, AA) に変換する。 */
function columnLabel(n: number): string {
let s = '';
let x = n + 1;
while (x > 0) {
const r = (x - 1) % 26;
s = String.fromCharCode(65 + r) + s;
x = Math.floor((x - 1) / 26);
}
return s;
}
function OfficeSpreadsheetView({ data }: { data: OfficeSpreadsheetPreview }): JSX.Element {
const { t } = useTranslation('files');
const [active, setActive] = useState(0);
const sheet = data.sheets[active] ?? data.sheets[0];
if (!sheet) return <p className="text-sm text-slate-400">{t('preview.noSheets')}</p>;
const cols = sheet.rows.reduce((m, r) => Math.max(m, r.length), 0);
return (
<div className="flex flex-col gap-2">
{data.sheets.length > 1 && (
<div className="flex flex-wrap gap-1 border-b border-hairline pb-2">
{data.sheets.map((s, i) => (
<button
key={i}
type="button"
onClick={() => setActive(i)}
className={`h-7 rounded-md border px-2.5 text-xs transition-colors ${i === active ? 'border-accent bg-accent text-accent-fg' : 'border-hairline bg-canvas text-slate-700 hover:bg-surface'}`}
>
{s.name}
</button>
))}
</div>
)}
<div className="overflow-auto max-h-[74vh]">
<table className="border-collapse text-xs">
<thead>
<tr>
<th className="sticky left-0 top-0 z-20 border border-slate-200 bg-slate-100 px-2 py-1" />
{Array.from({ length: cols }, (_, c) => (
<th key={c} className="sticky top-0 z-10 border border-slate-200 bg-slate-100 px-2 py-1 text-center font-semibold text-slate-500">
{columnLabel(c)}
</th>
))}
</tr>
</thead>
<tbody>
{sheet.rows.map((row, ri) => (
<tr key={ri}>
<td className="sticky left-0 z-10 select-none border border-slate-200 bg-slate-100 px-2 py-1 text-right text-slate-400">{ri + 1}</td>
{Array.from({ length: cols }, (_, ci) => (
<td key={ci} className="max-w-[320px] truncate border border-slate-200 bg-canvas px-2 py-1 align-top" title={row[ci] ?? ''}>
{row[ci] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{sheet.truncated && (
<p className="text-2xs text-slate-400">
{t('preview.sheetTruncated', { shown: sheet.rows.length, rows: sheet.rowCount, cols: sheet.colCount })}
</p>
)}
</div>
);
}
function OfficePresentationView({ data }: { data: OfficePresentationPreview }): JSX.Element {
const { t } = useTranslation('files');
if (data.slides.length === 0) return <p className="text-sm text-slate-400">{t('preview.noSlides')}</p>;
return (
<div className="flex flex-col items-center gap-4">
{data.slides.map((s) => (
<div key={s.index} className="w-full max-w-3xl">
<div className="mb-1 text-2xs text-slate-400">{t('preview.slide', { index: s.index })}</div>
<img src={s.dataUrl} alt={t('preview.slideAlt', { index: s.index })} className="w-full rounded-lg border border-hairline shadow-sm" />
</div>
))}
{data.truncated && (
<p className="text-2xs text-slate-400">{t('preview.slidesTruncated', { shown: data.slides.length, total: data.slideCount })}</p>
)}
</div>
);
}
function OfficePreviewView({ office }: { office: OfficePreviewDescriptor }): JSX.Element {
const { t } = useTranslation('files');
const [data, setData] = useState<OfficePreview | null>(null);
const [err, setErr] = useState<{ message: string; unavailable: boolean } | null>(null);
useEffect(() => {
let alive = true;
setData(null);
setErr(null);
fetchOfficePreview(office.url)
.then((d) => { if (alive) setData(d); })
.catch((e) => {
if (!alive) return;
const unavailable = e instanceof OfficePreviewError && e.code === 'converter_unavailable';
setErr({ message: e instanceof Error ? e.message : t('preview.loadFailed'), unavailable });
});
return () => { alive = false; };
}, [office.url, t]);
if (err) {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center">
<p className="max-w-md text-sm text-slate-600">
{err.unavailable
? t('preview.converterUnavailable')
: t('preview.previewFailed', { message: err.message })}
</p>
{office.downloadUrl && (
<a
href={office.downloadUrl}
download
className="inline-flex h-8 items-center rounded-md border border-hairline bg-canvas px-3 text-xs text-slate-700 hover:bg-surface"
>
{t('preview.downloadFile')}
</a>
)}
</div>
);
}
if (!data) {
return <div className="flex items-center justify-center py-16 text-sm text-slate-400">{t('preview.generating')}</div>;
}
return data.kind === 'spreadsheet'
? <OfficeSpreadsheetView data={data} />
: <OfficePresentationView data={data} />;
}
// --- CSV ---
@@ -166,11 +315,7 @@ function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string)
}
if (imageBaseUrl) {
renderer.image = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
let resolvedHref = href;
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
const cleanPath = href.replace(/^\.\//, '');
resolvedHref = `${imageBaseUrl}${encodeURIComponent(cleanPath)}`;
}
const resolvedHref = resolvePreviewImageHref(href, imageBaseUrl);
const titleAttr = title ? ` title="${title}"` : '';
return `<img src="${resolvedHref}" alt="${text}"${titleAttr} style="max-width:100%" />`;
};
@@ -623,7 +768,7 @@ function renderJsonl(content: string): JSX.Element {
}
// --- FilePreview ---
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl }: FilePreviewProps) {
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl, office }: FilePreviewProps) {
const { t } = useTranslation('files');
const [mode, setMode] = useState<'view' | 'edit'>('view');
const [editContent, setEditContent] = useState(content);
@@ -667,7 +812,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
setCurrentContent(editContent);
setMode('view');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
setError(err instanceof Error ? err.message : t('saveError'));
} finally {
setSaving(false);
}
@@ -703,6 +848,11 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
}
// view mode
// Office (Excel / PowerPoint): office-preview API から変換結果を取得して描画。
// imageSrc より先に判定する(office ファイルは raw だとバイナリ表示になるため)。
if (office) {
return <OfficePreviewView office={office} />;
}
if (imageSrc) {
if (/\.html?$/i.test(name)) {
return (
+104 -17
View File
@@ -6,8 +6,12 @@
* testid は `idPrefix` から組み立てる('space' → space-files-grid / space-file-tile …)。
* 既存 e2e の testid を保つため、idPrefix を変えるだけで両者を再現できるようにしている。
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalFileEntry } from '../../api';
import { FileTypeIcon } from './FileTypeIcon';
import { workspaceDirRole } from '../../lib/workspaceDirs';
import { DND_FILES_MIME, dragSources, readDragSources } from '../../lib/fileDnd';
interface FileTileGridProps {
/** 表示するエントリ(呼出側でソート済み)。 */
@@ -33,6 +37,18 @@ interface FileTileGridProps {
fileHref?: (entry: LocalFileEntry) => string;
/** タイル下部に差し込む任意のオーバーレイ(例: スペースの「アプリとして実行」)。 */
renderTileOverlay?: (entry: LocalFileEntry) => React.ReactNode;
/**
* Phase 2: 各タイル右上に差し込む任意アクション(リネーム等)。ファイル・フォルダ両方に
* 出る。詳細表示の renderRowAction と対をなす。
*/
renderEntryAction?: (entry: LocalFileEntry) => React.ReactNode;
/**
* Phase 2: ドラッグ移動。指定すると(かつ canManage が true なら)タイルをドラッグして
* フォルダタイルへドロップで移動できる。sourcePaths は選択を考慮した移動元集合。
*/
onMoveDrop?: (sourcePaths: string[], destDir: string) => void;
/** フォルダの zip ダウンロード。指定するとフォルダタイルに zip DL ボタンを出す(構造フォルダ含む全フォルダ)。 */
onDownloadDir?: (path: string) => void;
/** エントリ 0 件のとき表示するヒント。 */
emptyHint?: React.ReactNode;
}
@@ -49,8 +65,14 @@ export function FileTileGrid({
isDeleting,
fileHref,
renderTileOverlay,
renderEntryAction,
onMoveDrop,
onDownloadDir,
emptyHint,
}: FileTileGridProps) {
const { t } = useTranslation('files');
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
const dndEnabled = canManage && !!onMoveDrop;
return (
<div
data-testid={`${idPrefix}-files-grid`}
@@ -58,13 +80,43 @@ export function FileTileGrid({
>
{entries.map(entry => {
const isFile = entry.kind !== 'directory';
const isChecked = isFile && selected.has(entry.path);
const showControls = isFile && canManage;
// 構造フォルダ(input/output/... = role 有)は足場なので選択・削除・移動の対象外。
// ダウンロードは全フォルダ可(下の onDownloadDir)。
const dirRole = workspaceDirRole(entry.path, entry.name, entry.kind);
const canSelectDelete = canManage && (isFile || !dirRole);
const isChecked = canSelectDelete && selected.has(entry.path);
const isDropTarget = dndEnabled && entry.kind === 'directory';
const isDragOver = isDropTarget && dragOverPath === entry.path;
const entryAction = renderEntryAction?.(entry);
const canDrag = dndEnabled && !dirRole;
return (
<div
key={`${entry.kind}:${entry.path}`}
draggable={canDrag || undefined}
onDragStart={canDrag ? e => {
e.dataTransfer.setData(DND_FILES_MIME, JSON.stringify(dragSources(entry, selected)));
e.dataTransfer.effectAllowed = 'move';
} : undefined}
onDragOver={isDropTarget ? e => {
if (!e.dataTransfer.types.includes(DND_FILES_MIME)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverPath !== entry.path) setDragOverPath(entry.path);
} : undefined}
onDragLeave={isDropTarget ? () => setDragOverPath(p => (p === entry.path ? null : p)) : undefined}
onDrop={isDropTarget ? e => {
e.preventDefault();
setDragOverPath(null);
const sources = readDragSources(e.dataTransfer);
if (sources.length) onMoveDrop!(sources, entry.path);
} : undefined}
data-drop-target={isDropTarget ? 'true' : undefined}
className={`group relative rounded-lg border transition-colors ${
isChecked ? 'border-[var(--brand-primary)] bg-surface' : 'border-transparent hover:border-hairline hover:bg-surface'
isDragOver
? 'border-[var(--brand-primary)] ring-2 ring-[var(--brand-primary)] bg-surface'
: isChecked
? 'border-[var(--brand-primary)] bg-surface'
: 'border-transparent hover:border-hairline hover:bg-surface'
}`}
>
<button
@@ -95,48 +147,83 @@ export function FileTileGrid({
>
{entry.name}
</span>
{(() => {
const role = workspaceDirRole(entry.path, entry.name, entry.kind);
if (!role) return null;
return (
<span
data-testid={`${idPrefix}-dir-badge-${entry.name}`}
data-writable={role.writable ? 'true' : 'false'}
title={role.title}
className={`inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-medium ${role.className}`}
>
{!role.writable && (
<svg className="h-2.5 w-2.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
<rect x="3.5" y="7" width="9" height="6" rx="1" />
<path d="M5.5 7V5a2.5 2.5 0 015 0v2" />
</svg>
)}
{role.label}
</span>
);
})()}
</button>
{renderTileOverlay?.(entry)}
{showControls && (
{canSelectDelete && (
<input
type="checkbox"
data-testid={`${idPrefix}-file-select-${entry.name}`}
checked={isChecked}
onChange={() => onToggleSelect(entry.path)}
title="選択"
aria-label={`${entry.name} を選択`}
className={`absolute left-1.5 top-1.5 h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)] transition-opacity ${
title={t('tile.select')}
aria-label={t('tile.selectAria', { name: entry.name })}
className={`absolute left-1.5 top-1.5 h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)] transition-opacity reveal-hover ${
isChecked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100'
}`}
/>
)}
{/* 右上クラスタ: ダウンロード(全員)+ 削除(編集権あり時)。ファイルのみ。 */}
{isFile && (fileHref || showControls) && (
{/* 右上クラスタ: リネーム等(ファイル/フォルダ)+ ダウンロード(ファイル=直/フォルダ=zip)+ 削除。 */}
{(entryAction || (isFile && fileHref) || (entry.kind === 'directory' && onDownloadDir) || canSelectDelete) && (
<div className="absolute right-1 top-1 flex items-center gap-0.5">
{fileHref && (
{entryAction}
{isFile && fileHref && (
<a
href={fileHref(entry)}
download={entry.name}
onClick={e => e.stopPropagation()}
data-testid={`${idPrefix}-file-download-${entry.name}`}
title="ダウンロード"
aria-label={`${entry.name} をダウンロード`}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100"
title={t('tile.download')}
aria-label={t('tile.downloadAria', { name: entry.name })}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
</svg>
</a>
)}
{showControls && (
{entry.kind === 'directory' && onDownloadDir && (
<button
type="button"
data-testid={`${idPrefix}-dir-download-${entry.name}`}
onClick={e => { e.stopPropagation(); onDownloadDir(entry.path); }}
title={t('tile.dirDownload')}
aria-label={t('tile.dirDownloadAria', { name: entry.name })}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
</svg>
</button>
)}
{canSelectDelete && (
<button
type="button"
data-testid={`${idPrefix}-file-delete-${entry.name}`}
onClick={() => onDeleteOne(entry.path)}
disabled={isDeleting}
title="削除"
aria-label={`${entry.name} を削除`}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-700 focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 dark:hover:bg-red-500/15 dark:hover:text-red-300"
title={entry.kind === 'directory' ? t('tile.deleteDir') : t('tile.delete')}
aria-label={t('tile.deleteAria', { name: entry.name })}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover disabled:opacity-50 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
+164 -10
View File
@@ -5,10 +5,103 @@
* - FileActions … 「追加」ボタン + 隠し input + 再読み込み
* - FileSelectionBar … すべて選択 + 選択件数 + 削除(複数選択)
* - FileDropzone … 子をラップし、ドラッグ&ドロップでアップロードを受ける
* - FileViewToggle … アイコン表示 / 詳細表示 の切替(セグメント)
*/
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { FileViewMode } from '../../lib/fileView';
export { filesToBase64 } from '../../lib/fileBase64';
/**
* アイコン表示時の並べ替えメニュー(名前順 / 新しい順)。タスク窓・ワークスペース窓で共有。
* 共有ソート状態(FileSortState)を持つ呼出側が、name/modified に縮約してこの 2 値で操作する。
*/
export type FileSort = 'name' | 'newest';
const SORT_OPTIONS: Array<{ value: FileSort; labelKey: string }> = [
{ value: 'name', labelKey: 'sort.name' },
{ value: 'newest', labelKey: 'sort.newest' },
];
export function FileSortMenu({ sort, onChange }: { sort: FileSort; onChange: (s: FileSort) => void }) {
const { t } = useTranslation('files');
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) return;
const handleMouseDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [open]);
const current = SORT_OPTIONS.find(o => o.value === sort) ?? SORT_OPTIONS[0];
const handleSelect = (value: FileSort) => {
onChange(value);
setOpen(false);
triggerRef.current?.focus();
};
return (
<div ref={containerRef} className="relative flex-shrink-0">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(v => !v)}
title={t('sort.tooltip', { mode: t(current.labelKey) })}
aria-haspopup="true"
aria-expanded={open}
aria-label={t('sort.tooltip', { mode: t(current.labelKey) })}
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
open ? 'bg-accent-soft text-accent' : 'text-slate-500 hover:text-slate-900 hover:bg-surface-2'
}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3" />
</svg>
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+6px)] z-10 bg-canvas border border-hairline rounded-md shadow min-w-[140px] p-1">
{SORT_OPTIONS.map(o => {
const selected = sort === o.value;
return (
<button
key={o.value}
type="button"
onClick={() => handleSelect(o.value)}
className={`flex items-center justify-between w-full px-2.5 py-1.5 rounded text-xs text-left transition-colors ${
selected ? 'bg-accent-soft text-accent font-semibold' : 'text-slate-700 font-medium hover:bg-surface-2'
}`}
>
{t(o.labelKey)}
{selected && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
)}
</div>
);
}
const ICON_BTN =
'w-8 h-8 flex items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 hover:text-slate-900 hover:bg-surface transition-colors';
@@ -28,6 +121,7 @@ export function FileActions({
isRefreshing?: boolean;
isUploading?: boolean;
}) {
const { t } = useTranslation('files');
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex shrink-0 items-center gap-1.5">
@@ -55,7 +149,7 @@ export function FileActions({
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 3.5v9M3.5 8h9" />
</svg>
{t('toolbar.add')}
</button>
</>
)}
@@ -64,8 +158,8 @@ export function FileActions({
onClick={onRefresh}
disabled={isRefreshing}
className={`${ICON_BTN} disabled:opacity-50`}
title="再読み込み"
aria-label="再読み込み"
title={t('toolbar.refresh')}
aria-label={t('toolbar.refresh')}
>
<svg className={`h-3.5 w-3.5 ${isRefreshing ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 8a6 6 0 0110.5-4M14 8a6 6 0 01-10.5 4" />
@@ -83,8 +177,10 @@ export function FileSelectionBar({
selectedCount,
onDeleteSelected,
onDownloadSelected,
onMoveSelected,
isDeleting,
isDownloading,
isMoving,
}: {
idPrefix: string;
allSelected: boolean;
@@ -92,9 +188,13 @@ export function FileSelectionBar({
selectedCount: number;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
/** Phase 2: 指定すると「移動」ボタンを出す(移動先ダイアログを開く)。 */
onMoveSelected?: () => void;
isDeleting?: boolean;
isDownloading?: boolean;
isMoving?: boolean;
}) {
const { t } = useTranslation('files');
return (
<div className="flex flex-wrap items-center gap-2 text-2xs text-slate-500">
<label className="inline-flex cursor-pointer items-center gap-1.5 select-none">
@@ -105,13 +205,28 @@ export function FileSelectionBar({
onChange={onToggleSelectAll}
className="h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)]"
/>
{t('toolbar.selectAll')}
</label>
<span data-testid={`${idPrefix}-files-selected-count`} className="text-slate-400">
{selectedCount}
{t('toolbar.selectedCount', { count: selectedCount })}
</span>
{selectedCount > 0 && (
<div className="ml-auto flex items-center gap-1.5">
{onMoveSelected && (
<button
type="button"
data-testid={`${idPrefix}-files-move-selected`}
onClick={onMoveSelected}
disabled={isMoving}
className="inline-flex h-7 items-center gap-1 rounded-md border border-hairline bg-canvas px-2.5 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface hover:text-slate-900 disabled:opacity-50"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 4.5A1.5 1.5 0 013.5 3h2.6l1.2 1.6h5.2A1.5 1.5 0 0116 6.1V12a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 012 12V4.5z" />
<path d="M9 8.5h3M10.5 7l1.5 1.5L10.5 10" />
</svg>
{t('toolbar.move')}
</button>
)}
<button
type="button"
data-testid={`${idPrefix}-files-download-selected`}
@@ -122,7 +237,7 @@ export function FileSelectionBar({
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
</svg>
{t('toolbar.download')}
</button>
<button
type="button"
@@ -134,7 +249,7 @@ export function FileSelectionBar({
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
{t('toolbar.delete')}
</button>
</div>
)}
@@ -159,6 +274,7 @@ export function FileDropzone({
onRejectFolder?: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation('files');
const [dragDepth, setDragDepth] = useState(0);
// ウィンドウ外への drag 離脱や drop 取りこぼしで dragDepth が戻らずオーバーレイが
@@ -196,15 +312,53 @@ export function FileDropzone({
>
{enabled && dragDepth > 0 && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-slate-400 bg-canvas/85 text-sm font-medium text-slate-600">
{t('toolbar.dropToAdd')}
</div>
)}
{enabled && isUploading && (
<div className="pointer-events-none absolute right-1 top-1 z-10 rounded bg-slate-800/80 px-2 py-0.5 text-2xs text-white">
{t('toolbar.uploading')}
</div>
)}
{children}
</div>
);
}
/**
* アイコン表示 / 詳細表示 の切替セグメント。状態は呼出側(useFileView)が持つ。
*/
export function FileViewToggle({
idPrefix,
mode,
onChange,
}: {
idPrefix: string;
mode: FileViewMode;
onChange: (mode: FileViewMode) => void;
}) {
const { t } = useTranslation('files');
const btn = (m: FileViewMode, label: string, path: JSX.Element) => (
<button
type="button"
data-testid={`${idPrefix}-files-view-${m}`}
onClick={() => onChange(m)}
aria-pressed={mode === m}
title={label}
aria-label={label}
className={`flex h-8 w-8 items-center justify-center transition-colors ${
mode === m ? 'bg-surface text-slate-900' : 'bg-canvas text-slate-500 hover:bg-surface hover:text-slate-900'
}`}
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
{path}
</svg>
</button>
);
return (
<div className="flex shrink-0 overflow-hidden rounded-md border border-hairline" role="group" aria-label={t('toolbar.viewToggle')}>
{btn('icon', t('toolbar.iconView'), <><rect x="2" y="2" width="5" height="5" rx="1" /><rect x="9" y="2" width="5" height="5" rx="1" /><rect x="2" y="9" width="5" height="5" rx="1" /><rect x="9" y="9" width="5" height="5" rx="1" /></>)}
{btn('detail', t('toolbar.detailView'), <><path d="M5.5 4h8M5.5 8h8M5.5 12h8" /><path d="M2.5 4h.01M2.5 8h.01M2.5 12h.01" /></>)}
</div>
);
}
@@ -0,0 +1,141 @@
/**
* MoveTargetDialog — 複数選択した項目の移動先フォルダを選ぶ軽量ダイアログ(Box 化 Phase 2)。
* ドラッグ移動のキーボード/タッチ代替。フォルダだけを辿り、「ここに移動」で確定する。
*
* 実際の移動(move API 連打)と結果表示は呼出側(SpaceFiles)が onConfirm で行う。
* ここは移動先 dir を決めることに専念し、no-op になる移動先では確定ボタンを無効化する。
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { fetchSpaceFiles, type LocalFileEntry } from '../../api';
import { resolveMoves } from '../../lib/fileMove';
import { FileBreadcrumb } from './FileBreadcrumb';
interface MoveTargetDialogProps {
spaceId: string;
/** 移動する項目の相対パス集合。 */
sourcePaths: string[];
onClose: () => void;
onConfirm: (destDir: string) => void;
isMoving?: boolean;
}
export function MoveTargetDialog({ spaceId, sourcePaths, onClose, onConfirm, isMoving }: MoveTargetDialogProps) {
const { t } = useTranslation('files');
const [dir, setDir] = useState('');
const [folders, setFolders] = useState<LocalFileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async (target: string) => {
setLoading(true);
setError('');
try {
const r = await fetchSpaceFiles(spaceId, target);
setFolders(r.entries.filter(e => e.kind === 'directory'));
} catch {
setFolders([]);
setError(t('move.loadFoldersError'));
} finally {
setLoading(false);
}
}, [spaceId, t]);
useEffect(() => { void load(dir); }, [dir, load]);
const { moves, skipped } = resolveMoves(sourcePaths, dir);
const segments = dir ? dir.split('/').filter(Boolean) : [];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
role="presentation"
>
<div
data-testid="move-target-dialog"
role="dialog"
aria-modal="true"
aria-label={t('move.title', { count: sourcePaths.length })}
className="w-full max-w-md rounded-lg border border-hairline bg-canvas shadow-xl"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-hairline px-4 py-2.5">
<h2 className="text-sm font-semibold text-slate-800">{t('move.title', { count: sourcePaths.length })}</h2>
<button
type="button"
onClick={onClose}
aria-label={t('move.close')}
className="rounded p-1 text-slate-400 hover:bg-surface hover:text-slate-700"
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
<div className="px-4 py-3">
<div className="mb-2">
<FileBreadcrumb testid="move-target-breadcrumb" pathSegments={segments} onNavigate={setDir} />
</div>
<div className="max-h-64 overflow-y-auto rounded border border-hairline">
{loading ? (
<p className="px-3 py-6 text-center text-xs text-slate-400">{t('move.loading')}</p>
) : folders.length === 0 ? (
<p className="px-3 py-6 text-center text-xs text-slate-400">
{t('move.noSubfolders')}
</p>
) : (
<ul className="divide-y divide-hairline/60">
{folders.map(f => (
<li key={f.path}>
<button
type="button"
data-testid={`move-target-folder-${f.name}`}
onClick={() => setDir(f.path)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-slate-700 hover:bg-surface"
>
<svg className="h-4 w-4 shrink-0 text-amber-400" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
</svg>
<span className="truncate">{f.name}</span>
<svg className="ml-auto h-3.5 w-3.5 shrink-0 text-slate-300" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4l4 4-4 4" />
</svg>
</button>
</li>
))}
</ul>
)}
</div>
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
<p className="mt-2 text-2xs text-slate-500">
{t('move.destination')} <span className="font-medium text-slate-700">{dir ? `/${dir}` : t('move.root')}</span>
{skipped.length > 0 && <span className="text-slate-400">{t('move.skipped', { count: skipped.length })}</span>}
</p>
</div>
<div className="flex items-center justify-end gap-2 border-t border-hairline px-4 py-2.5">
<button
type="button"
onClick={onClose}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-3 text-2xs font-medium text-slate-600 hover:bg-surface"
>
{t('move.cancel')}
</button>
<button
type="button"
data-testid="move-target-confirm"
onClick={() => onConfirm(dir)}
disabled={isMoving || moves.length === 0}
className="inline-flex h-7 items-center rounded-md bg-[var(--brand-primary)] px-3 text-2xs font-semibold text-white hover:opacity-90 disabled:opacity-40"
>
{moves.length > 0 ? t('move.confirmCount', { count: moves.length }) : t('move.confirm')}
</button>
</div>
</div>
</div>
);
}
-10
View File
@@ -33,16 +33,6 @@ const ICON_PROPS = {
};
const NAV_ICONS: Record<PageId, ReactNode> = {
tasks: (
<svg {...ICON_PROPS}>
<line x1="8" y1="6" x2="20" y2="6" />
<line x1="8" y1="12" x2="20" y2="12" />
<line x1="8" y1="18" x2="20" y2="18" />
<circle cx="4" cy="6" r="1.4" />
<circle cx="4" cy="12" r="1.4" />
<circle cx="4" cy="18" r="1.4" />
</svg>
),
spaces: (
<svg {...ICON_PROPS}>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
-1
View File
@@ -25,7 +25,6 @@ interface TopBarProps {
// labelKey resolves against the `layout` i18n namespace at render time (module
// scope can't call hooks). All consumers translate: TopBar, NavDrawer, App.tsx.
export const NAV_ITEMS: Array<{ id: PageId; labelKey: string; adminOnly: boolean; requiresAuth: boolean }> = [
{ id: 'tasks', labelKey: 'nav.tasks', adminOnly: false, requiresAuth: false },
{ id: 'spaces', labelKey: 'nav.spaces', adminOnly: false, requiresAuth: false },
{ id: 'calendar', labelKey: 'nav.calendar', adminOnly: false, requiresAuth: false },
{ id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false },
+77
View File
@@ -0,0 +1,77 @@
// @vitest-environment jsdom
/**
* Component tests for FilterBar — the search box, sort menu, and status filter
* tabs that drive the task list. Verifies counts render, callbacks fire on user
* interaction (search input, status tab click, sort selection), and aria state
* reflects the current selection.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import '../../i18n'; // initialize i18next so useTranslation('list') resolves
import { FilterBar } from './FilterBar';
function baseProps(overrides: Record<string, unknown> = {}) {
return {
selectedStatus: 'all' as const,
sortMode: 'updated' as const,
searchQuery: '',
counts: { running: 2, failed: 1, queued: 5 },
totalCount: 8,
onStatusChange: vi.fn(),
onSortChange: vi.fn(),
onSearchChange: vi.fn(),
...overrides,
};
}
describe('FilterBar', () => {
it('renders the total count on the All tab and per-status counts', () => {
renderWithProviders(<FilterBar {...baseProps()} />);
const allTab = screen.getByRole('tab', { name: /All/ });
expect(allTab).toHaveTextContent('8');
// Running status tab shows its count from the counts map.
const runningTab = screen.getByRole('tab', { name: /Running/ });
expect(runningTab).toHaveTextContent('2');
});
it('marks the selected status tab as aria-selected', () => {
renderWithProviders(<FilterBar {...baseProps({ selectedStatus: 'running' })} />);
expect(screen.getByRole('tab', { name: /Running/ })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: /All/ })).toHaveAttribute('aria-selected', 'false');
});
it('calls onSearchChange as the user types in the search box', async () => {
const user = userEvent.setup();
const props = baseProps();
renderWithProviders(<FilterBar {...props} />);
const input = screen.getByRole('textbox', { name: /Search/i });
await user.type(input, 'a');
expect(props.onSearchChange).toHaveBeenCalledWith('a');
});
it('calls onStatusChange when a status tab is clicked', async () => {
const user = userEvent.setup();
const props = baseProps();
renderWithProviders(<FilterBar {...props} />);
await user.click(screen.getByRole('tab', { name: /Failed/ }));
expect(props.onStatusChange).toHaveBeenCalledWith('failed');
});
it('opens the sort menu and calls onSortChange with the picked mode', async () => {
const user = userEvent.setup();
const props = baseProps();
renderWithProviders(<FilterBar {...props} />);
// Sort menu is collapsed initially.
const trigger = screen.getByRole('button', { name: /Sort/i });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
await user.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
// Pick "By title".
const menu = trigger.closest('div')!;
await user.click(within(menu).getByRole('button', { name: 'By title' }));
expect(props.onSortChange).toHaveBeenCalledWith('title');
});
});
@@ -0,0 +1,88 @@
// @vitest-environment jsdom
/**
* Component tests for LocalTaskListItem — a single row in the task list. Verifies
* the title/id/body render, the status badge reflects latestJob.status (falling
* back to "queued"/Inbox when absent), the subtask progress fraction shows, the
* visibility chip varies by visibility, and clicking fires onClick.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import type { LocalTask } from '../../api';
import { LocalTaskListItem } from './TaskListItem';
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
return {
id: 42,
title: 'Build the report',
body: 'Generate a quarterly sales report from the uploaded spreadsheet.',
pieceName: 'chat',
profile: 'default',
outputFormat: 'markdown',
askPolicy: 'auto',
priority: 'normal',
state: 'open',
workspacePath: null,
createdAt: '2026-06-01T00:00:00Z',
updatedAt: '2026-06-01T00:00:00Z',
latestJob: { id: 'job-1', status: 'running' },
...overrides,
};
}
describe('LocalTaskListItem', () => {
it('renders the title, id and body for a running task', () => {
renderWithProviders(<LocalTaskListItem task={makeTask()} active={false} onClick={() => {}} />);
expect(screen.getByText('Build the report')).toBeInTheDocument();
expect(screen.getByText('#42')).toBeInTheDocument();
expect(screen.getByText(/quarterly sales report/)).toBeInTheDocument();
// Status badge reflects latestJob.status.
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('falls back to the queued/Inbox status when latestJob is missing', () => {
renderWithProviders(
<LocalTaskListItem task={makeTask({ latestJob: null })} active={false} onClick={() => {}} />,
);
expect(screen.getByText('Inbox')).toBeInTheDocument();
});
it('shows the subtask completed/total fraction when subtasks exist', () => {
renderWithProviders(
<LocalTaskListItem
task={makeTask({ subtaskCount: 4, subtaskCompleted: 1 })}
active={false}
onClick={() => {}}
/>,
);
expect(screen.getByText('1/4')).toBeInTheDocument();
});
it('renders the private visibility chip', () => {
renderWithProviders(
<LocalTaskListItem task={makeTask({ visibility: 'private' })} active={false} onClick={() => {}} />,
);
expect(screen.getByText('private')).toBeInTheDocument();
});
it('renders the org name as the visibility chip for org visibility', () => {
renderWithProviders(
<LocalTaskListItem
task={makeTask({ visibility: 'org', visibilityScopeOrgName: 'Acme' })}
active={false}
onClick={() => {}}
/>,
);
expect(screen.getByText('Acme')).toBeInTheDocument();
});
it('calls onClick when the row is clicked', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
renderWithProviders(<LocalTaskListItem task={makeTask()} active={false} onClick={onClick} />);
await user.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,120 @@
// @vitest-environment jsdom
/**
* Component tests for TaskListPanel — the list container that wires FilterBar +
* TaskListItem to the filter/sort libs. Verifies the full task set renders, the
* status filter narrows the rendered rows, the search query narrows them, the
* empty-state shows when nothing matches, the summary header counts are correct,
* and selecting a row fires onSelectTask with the task id.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import '../../i18n';
import type { LocalTask } from '../../api';
import { TaskListPanel } from './TaskListPanel';
function makeTask(id: number, title: string, status: string, body = ''): LocalTask {
return {
id,
title,
body,
pieceName: 'chat',
profile: 'default',
outputFormat: 'markdown',
askPolicy: 'auto',
priority: 'normal',
state: 'open',
workspacePath: null,
createdAt: '2026-06-01T00:00:00Z',
updatedAt: `2026-06-0${id}T00:00:00Z`,
latestJob: { id: `job-${id}`, status },
};
}
const TASKS: LocalTask[] = [
makeTask(1, 'Alpha report', 'running', 'first task body'),
makeTask(2, 'Beta export', 'failed', 'second task body'),
makeTask(3, 'Gamma sync', 'running', 'third task body'),
];
function baseProps(overrides: Record<string, unknown> = {}) {
return {
localTasks: TASKS,
selectedStatus: 'all' as const,
sortMode: 'updated' as const,
searchQuery: '',
activeTaskId: null,
onStatusChange: vi.fn(),
onSortChange: vi.fn(),
onSearchChange: vi.fn(),
onSelectTask: vi.fn(),
onOpenCreate: vi.fn(),
...overrides,
};
}
describe('TaskListPanel', () => {
it('renders every task when status=all and no query', () => {
renderWithProviders(<TaskListPanel {...baseProps()} />);
expect(screen.getByText('Alpha report')).toBeInTheDocument();
expect(screen.getByText('Beta export')).toBeInTheDocument();
expect(screen.getByText('Gamma sync')).toBeInTheDocument();
});
it('filters the rendered rows by selected status', () => {
renderWithProviders(<TaskListPanel {...baseProps({ selectedStatus: 'failed' })} />);
expect(screen.getByText('Beta export')).toBeInTheDocument();
expect(screen.queryByText('Alpha report')).not.toBeInTheDocument();
expect(screen.queryByText('Gamma sync')).not.toBeInTheDocument();
});
it('filters the rendered rows by search query (title match)', () => {
renderWithProviders(<TaskListPanel {...baseProps({ searchQuery: 'gamma' })} />);
expect(screen.getByText('Gamma sync')).toBeInTheDocument();
expect(screen.queryByText('Alpha report')).not.toBeInTheDocument();
expect(screen.queryByText('Beta export')).not.toBeInTheDocument();
});
it('shows the empty-state when no task matches', () => {
renderWithProviders(<TaskListPanel {...baseProps({ searchQuery: 'nonexistent-xyz' })} />);
expect(screen.getByText('No threads yet')).toBeInTheDocument();
expect(screen.queryByText('Alpha report')).not.toBeInTheDocument();
});
it('shows the summary header counts (total + running)', () => {
renderWithProviders(<TaskListPanel {...baseProps()} />);
// total = 3, running = 2. The summary header carries these.
const allTab = screen.getByRole('tab', { name: /All/ });
expect(allTab).toHaveTextContent('3');
expect(screen.getByRole('tab', { name: /Running/ })).toHaveTextContent('2');
});
it('fires onSelectTask with the task id when a row is clicked', async () => {
const user = userEvent.setup();
const props = baseProps();
renderWithProviders(<TaskListPanel {...props} />);
await user.click(screen.getByText('Beta export'));
expect(props.onSelectTask).toHaveBeenCalledWith(2);
});
it('fires onOpenCreate when the new-request button is clicked', async () => {
const user = userEvent.setup();
const props = baseProps();
renderWithProviders(<TaskListPanel {...props} />);
await user.click(screen.getByRole('button', { name: /New request/i }));
expect(props.onOpenCreate).toHaveBeenCalledTimes(1);
});
it('renders the scope toggle only when scope is enabled', () => {
const { rerender } = renderWithProviders(
<TaskListPanel {...baseProps({ scopeEnabled: false })} />,
);
expect(screen.queryByRole('group', { name: /scope/i })).not.toBeInTheDocument();
rerender(
<TaskListPanel {...baseProps({ scopeEnabled: true, onScopeChange: vi.fn(), currentUserId: 'u1' })} />,
);
expect(screen.getByRole('group', { name: /scope/i })).toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
/**
* Component tests for AuthForm (settings → auth.*).
*
* Focus: render with realistic config, the primary-provider <select>, the
* adminEmails StringArrayEditor, and the sessionMaxAge number serialization
* (string → Number, empty → undefined). Network is not used by this form
* (pure SectionFormProps presentational component).
*/
import '../../test/dom-setup';
import { beforeAll, describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import { AuthForm } from './AuthForm';
beforeAll(async () => {
await i18n.changeLanguage('en');
});
const noEnv = {};
function render(config: any, onChange = vi.fn()) {
renderWithProviders(
<AuthForm config={config} onChange={onChange} overriddenByEnv={noEnv} />,
);
return onChange;
}
describe('AuthForm', () => {
it('renders provider fields with realistic existing config', () => {
render({
auth: {
primaryProvider: 'gitea',
adminEmails: ['[email protected]'],
providers: { gitea: { baseUrl: 'https://gitea.example.com', clientId: 'cid' } },
},
});
// primary provider select reflects stored value
const select = screen.getByDisplayValue('gitea only') as HTMLSelectElement;
expect(select.value).toBe('gitea');
// existing admin email chip rendered by StringArrayEditor
expect(screen.getByText('[email protected]')).toBeInTheDocument();
// gitea base url field carries the stored value
expect(screen.getByDisplayValue('https://gitea.example.com')).toBeInTheDocument();
});
it('emits auth.primaryProvider when the select changes', async () => {
const user = userEvent.setup();
const onChange = render({ auth: {} });
const select = screen.getByDisplayValue('(none — all enabled)');
await user.selectOptions(select, 'google');
expect(onChange).toHaveBeenCalledWith('auth.primaryProvider', 'google');
});
it('serializes sessionMaxAge to a Number', () => {
const onChange = render({ auth: {} });
const input = screen.getByRole('spinbutton'); // type=number FieldInput
fireEvent.change(input, { target: { value: '86400000' } });
expect(onChange).toHaveBeenCalledWith('auth.sessionMaxAge', 86400000);
});
it('clears sessionMaxAge to undefined when emptied', () => {
const onChange = render({ auth: { sessionMaxAge: 86400000 } });
const input = screen.getByDisplayValue('86400000');
fireEvent.change(input, { target: { value: '' } });
expect(onChange).toHaveBeenLastCalledWith('auth.sessionMaxAge', undefined);
});
it('adds an admin email through the StringArrayEditor', async () => {
const user = userEvent.setup();
const onChange = render({ auth: { adminEmails: [] } });
const textInput = screen.getByPlaceholderText('[email protected]');
await user.type(textInput, '[email protected]');
await user.keyboard('{Enter}');
expect(onChange).toHaveBeenCalledWith('auth.adminEmails', ['[email protected]']);
});
it('toggles the secureCookie checkbox', async () => {
const user = userEvent.setup();
const onChange = render({ auth: {} });
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]); // secureCookie is the first checkbox
expect(onChange).toHaveBeenCalledWith('auth.secureCookie', true);
});
});
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
/**
* Component tests for BrowserSettingsForm (settings batch A).
*
* Focus: this form writes to TWO different config roots — `tools.*` (page/action
* timeouts) and `browser.*` (channel, sessions). Verifies the correct dotted
* path is used for each, number coercion, select changes, and the
* executablePath empty -> undefined contract.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { BrowserSettingsForm } from './BrowserSettingsForm';
function render(config: any = {}) {
const onChange = vi.fn();
renderWithProviders(
<BrowserSettingsForm config={config} onChange={onChange} overriddenByEnv={{}} />,
);
return { onChange };
}
function renderStateful(config: any = {}) {
const onChange = vi.fn();
const utils = renderStatefulForm(BrowserSettingsForm, config, { onChangeSpy: onChange });
return { onChange, ...utils };
}
describe('BrowserSettingsForm', () => {
it('renders defaults for both tools.* and browser.* fields', () => {
render({});
// Page timeout default 60000 (tools.*) and channel select default chromium (browser.*).
expect(screen.getByDisplayValue('60000')).toBeInTheDocument();
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
expect(selects[0]).toHaveValue('chromium');
});
it('writes the page timeout under the tools.* root as a number', async () => {
const { getConfig } = renderStateful({ tools: { browserPageTimeout: 60000 } });
const pageTimeout = screen.getByDisplayValue('60000');
await userEvent.clear(pageTimeout);
await userEvent.type(pageTimeout, '90000');
expect(getConfig().tools.browserPageTimeout).toBe(90000);
});
it('writes the browser channel under the browser.* root', async () => {
const { onChange } = render({ browser: { channel: 'chromium' } });
const channel = screen.getAllByRole('combobox')[0];
await userEvent.selectOptions(channel, 'chrome');
expect(onChange).toHaveBeenCalledWith('browser.channel', 'chrome');
});
it('serializes executablePath to undefined when emptied', async () => {
const { onChange } = render({ browser: { executablePath: '/usr/bin/chrome' } });
const exec = screen.getByDisplayValue('/usr/bin/chrome');
await userEvent.clear(exec);
expect(onChange).toHaveBeenLastCalledWith('browser.executablePath', undefined);
});
it('serializes idle TTL to undefined when emptied, number when set', async () => {
const { getConfig } = renderStateful({ browser: { taskSessionIdleTtl: 120 } });
const ttl = screen.getByDisplayValue('120');
await userEvent.clear(ttl);
expect(getConfig().browser.taskSessionIdleTtl).toBeUndefined();
await userEvent.type(ttl, '300');
expect(getConfig().browser.taskSessionIdleTtl).toBe(300);
});
});
@@ -0,0 +1,75 @@
// @vitest-environment jsdom
/**
* Component tests for ContextForm (settings batch A).
*
* Focus: the threshold-row serialization. Each row is { ratio, action }; editing
* a ratio must coerce to Number and rewrite the WHOLE thresholds array (not just
* one field), and editing an action must keep ratio intact. Also covers
* limitTokens empty -> undefined.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { ContextForm } from './ContextForm';
function render(config: any) {
const onChange = vi.fn();
renderWithProviders(<ContextForm config={config} onChange={onChange} overriddenByEnv={{}} />);
return { onChange };
}
function renderStateful(config: any) {
const onChange = vi.fn();
const utils = renderStatefulForm(ContextForm, config, { onChangeSpy: onChange });
return { onChange, ...utils };
}
const baseThresholds = [
{ ratio: 0.7, action: 'warn' },
{ ratio: 0.85, action: 'prompt' },
{ ratio: 0.95, action: 'force_transition' },
];
describe('ContextForm', () => {
it('renders default thresholds when none provided', () => {
render({ context: {} });
const ratioInputs = screen.getAllByRole('spinbutton') as HTMLInputElement[];
// 1 limitTokens + 3 threshold ratios.
expect(ratioInputs).toHaveLength(4);
// Three action selects.
expect(screen.getAllByRole('combobox')).toHaveLength(3);
});
it('serializes a ratio edit by rewriting the full thresholds array with a numeric ratio', async () => {
const { getConfig } = renderStateful({ context: { thresholds: baseThresholds } });
const ratioInputs = screen.getAllByRole('spinbutton');
// ratioInputs[0] is limitTokens; the first threshold ratio is index 1.
await userEvent.clear(ratioInputs[1]);
await userEvent.type(ratioInputs[1], '0.5');
const value = getConfig().context.thresholds;
expect(Array.isArray(value)).toBe(true);
expect(value).toHaveLength(3);
expect(value[0]).toEqual({ ratio: 0.5, action: 'warn' });
expect(typeof value[0].ratio).toBe('number');
// Other rows preserved.
expect(value[1]).toEqual({ ratio: 0.85, action: 'prompt' });
});
it('serializes an action edit while preserving that row ratio', async () => {
const { onChange } = render({ context: { thresholds: baseThresholds } });
const selects = screen.getAllByRole('combobox');
await userEvent.selectOptions(selects[1], 'force_transition');
const [path, value] = onChange.mock.calls.at(-1)!;
expect(path).toBe('context.thresholds');
expect(value[1]).toEqual({ ratio: 0.85, action: 'force_transition' });
});
it('serializes limitTokens as a number, and undefined when emptied', async () => {
const { onChange } = render({ context: { limitTokens: 8000 } });
const limit = screen.getAllByRole('spinbutton')[0];
await userEvent.clear(limit);
expect(onChange).toHaveBeenLastCalledWith('context.limitTokens', undefined);
});
});
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
/**
* Component tests for ExecutionForm (settings batch A).
*
* Focus: the comma-separated "Backoff Seconds" serialization (split, trim,
* Number, filter NaN), number coercion for concurrency/maxMovements with the
* empty -> undefined contract, and ENV-override disabling of the concurrency
* field.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { ExecutionForm } from './ExecutionForm';
function render(config: any = {}, overriddenByEnv: Record<string, boolean> = {}) {
const onChange = vi.fn();
renderWithProviders(
<ExecutionForm config={config} onChange={onChange} overriddenByEnv={overriddenByEnv} />,
);
return { onChange };
}
function renderStateful(config: any = {}) {
const onChange = vi.fn();
const utils = renderStatefulForm(ExecutionForm, config, { onChangeSpy: onChange });
return { onChange, ...utils };
}
describe('ExecutionForm', () => {
it('renders the default backoff seconds joined as a comma string', () => {
render({});
const backoff = screen.getByDisplayValue('60, 300, 900');
expect(backoff).toBeInTheDocument();
});
it('serializes concurrency to a number and undefined when emptied', async () => {
const { onChange, getConfig } = renderStateful({ concurrency: 4 });
const concurrency = screen.getAllByRole('spinbutton')[0];
await userEvent.clear(concurrency);
await userEvent.type(concurrency, '2');
expect(getConfig().concurrency).toBe(2);
await userEvent.clear(concurrency);
expect(getConfig().concurrency).toBeUndefined();
expect(onChange).toHaveBeenLastCalledWith('concurrency', undefined);
});
it('serializes backoff seconds: split on comma, trim, Number, drop NaN', () => {
// The field is a controlled text input that re-serializes to a number[] on
// every change; fire ONE change with the whole value (like a paste) so the
// parse is exercised on the complete string. Includes whitespace + a
// non-numeric token that must be dropped.
const { getConfig } = renderStateful({ retry: { backoffSeconds: [60] } });
const backoff = screen.getByDisplayValue('60');
fireEvent.change(backoff, { target: { value: '10, 20 , abc, 30' } });
expect(getConfig().retry.backoffSeconds).toEqual([10, 20, 30]);
});
it('disables the concurrency field when overridden by env', () => {
render({ concurrency: 4 }, { concurrency: true });
const concurrency = screen.getAllByRole('spinbutton')[0] as HTMLInputElement;
expect(concurrency).toBeDisabled();
});
});
@@ -194,6 +194,7 @@ export function GatewayKeysSection({ showToast }: Props) {
</div>
)}
{data && data.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
<tr>
@@ -321,6 +322,7 @@ export function GatewayKeysSection({ showToast }: Props) {
})}
</tbody>
</table>
</div>
)}
</div>
@@ -0,0 +1,126 @@
// @vitest-environment jsdom
/**
* Component tests for LlmWorkersForm (settings batch A).
*
* Focus: the worker-array editing serialization that has real logic —
* - addWorker pushes a default-shaped worker onto llm.workers
* - removeWorker / moveWorker rewrite the array correctly
* - the connectionType <select> keeps the legacy `proxy` flag in sync
* (aao_gateway -> proxy:true, direct -> proxy:undefined)
* - retry.backoffMs serialization (StringArrayEditor strings -> numbers, NaN dropped)
* - the self-loop endpoint warning heuristic (localhost) for gateway rows
*
* ModelSelect uses raw fetch for model discovery; we stub global fetch so no
* real network happens.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import { LlmWorkersForm } from './LlmWorkersForm';
function render(config: any) {
const onChange = vi.fn();
renderWithProviders(<LlmWorkersForm config={config} onChange={onChange} overriddenByEnv={{}} />);
return { onChange };
}
beforeEach(() => {
// Never let ModelSelect hit the network.
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })) as any,
);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('LlmWorkersForm', () => {
it('shows the empty state when there are no workers', () => {
render({ llm: { workers: [] } });
// empty -> i18n key 'llmWorkers.empty' (not initialized) appears as text.
expect(screen.getByText('llmWorkers.empty')).toBeInTheDocument();
});
it('addWorker appends a default-shaped worker to llm.workers', async () => {
const { onChange } = render({ llm: { workers: [] } });
await userEvent.click(screen.getByRole('button', { name: 'llmWorkers.addWorker' }));
const [path, value] = onChange.mock.calls.at(-1)!;
expect(path).toBe('llm.workers');
expect(value).toHaveLength(1);
expect(value[0]).toMatchObject({
connectionType: 'direct',
enabled: true,
maxConcurrency: 1,
roles: [],
});
});
it('removeWorker drops the row from the array', async () => {
const { onChange } = render({
llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }, { id: 'w2', endpoint: 'http://b/v1' }] },
});
await userEvent.click(screen.getAllByTitle('llmWorkers.removeWorker')[0]);
const [path, value] = onChange.mock.calls.at(-1)!;
expect(path).toBe('llm.workers');
expect(value).toHaveLength(1);
expect(value[0].id).toBe('w2');
});
it('moveWorker reorders rows', async () => {
const { onChange } = render({
llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }, { id: 'w2', endpoint: 'http://b/v1' }] },
});
// Second row's move-up button.
await userEvent.click(screen.getAllByTitle('llmWorkers.moveUp')[1]);
const [, value] = onChange.mock.calls.at(-1)!;
expect(value.map((w: any) => w.id)).toEqual(['w2', 'w1']);
});
it('keeps the legacy proxy flag in sync with connectionType', async () => {
const { onChange } = render({
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://a/v1' }] },
});
// The connection-type <select> is the only combobox in the row.
const select = screen.getByRole('combobox');
await userEvent.selectOptions(select, 'aao_gateway');
let [path, value] = onChange.mock.calls.at(-1)!;
expect(path).toBe('llm.workers');
expect(value[0]).toMatchObject({ connectionType: 'aao_gateway', proxy: true });
await userEvent.selectOptions(select, 'direct');
[path, value] = onChange.mock.calls.at(-1)!;
expect(value[0].connectionType).toBe('direct');
expect(value[0].proxy).toBeUndefined();
});
it('serializes retry.backoffMs from string chips to numbers, dropping NaN', async () => {
const { onChange } = render({ llm: { workers: [], retry: { backoffMs: [] } } });
// The backoff chip editor input has placeholder "2000".
const input = screen.getByPlaceholderText('2000');
await userEvent.type(input, 'abc{enter}');
// 'abc' -> NaN -> dropped, so the array stays empty.
let call = onChange.mock.calls.find(([p]) => p === 'llm.retry.backoffMs');
expect(call?.[1]).toEqual([]);
await userEvent.type(input, '1500{enter}');
call = onChange.mock.calls.filter(([p]) => p === 'llm.retry.backoffMs').at(-1);
expect(call?.[1]).toEqual([1500]);
});
it('shows the self-loop warning for a gateway worker pointing at localhost', () => {
render({
llm: { workers: [{ id: 'w1', connectionType: 'aao_gateway', endpoint: 'http://localhost:9876/v1' }] },
});
expect(screen.getByText('llmWorkers.selfLoopWarn')).toBeInTheDocument();
});
it('does NOT show the self-loop warning for a direct remote endpoint', () => {
render({
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://remote-box:11434/v1' }] },
});
expect(screen.queryByText('llmWorkers.selfLoopWarn')).toBeNull();
});
});
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
/**
* Component tests for MetricsForm (settings → llm.metrics.* + gateway.metrics.*).
*
* Focus: the two MetricsBlock instances write to distinct config paths,
* checkbox + text serialization (empty → undefined), and reading nested
* existing values.
*/
import '../../test/dom-setup';
import { beforeAll, describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import { MetricsForm } from './MetricsForm';
beforeAll(async () => {
await i18n.changeLanguage('en');
});
function render(config: any, onChange = vi.fn()) {
renderWithProviders(
<MetricsForm config={config} onChange={onChange} overriddenByEnv={{}} />,
);
return onChange;
}
describe('MetricsForm', () => {
it('reads nested enabled state for each block independently', () => {
render({
llm: { metrics: { enabled: true } },
gateway: { metrics: { enabled: false } },
});
const enableBoxes = screen.getAllByLabelText('Enable') as HTMLInputElement[];
expect(enableBoxes).toHaveLength(2);
expect(enableBoxes[0].checked).toBe(true); // worker (llm.metrics)
expect(enableBoxes[1].checked).toBe(false); // gateway.metrics
});
it('toggles the worker-metrics enable checkbox to llm.metrics.enabled', async () => {
const user = userEvent.setup();
const onChange = render({});
const enableBoxes = screen.getAllByLabelText('Enable');
await user.click(enableBoxes[0]);
expect(onChange).toHaveBeenCalledWith('llm.metrics.enabled', true);
});
it('toggles the gateway-metrics enable checkbox to gateway.metrics.enabled', async () => {
const user = userEvent.setup();
const onChange = render({});
const enableBoxes = screen.getAllByLabelText('Enable');
await user.click(enableBoxes[1]);
expect(onChange).toHaveBeenCalledWith('gateway.metrics.enabled', true);
});
it('serializes a cleared prefix to undefined', async () => {
const user = userEvent.setup();
const onChange = render({ llm: { metrics: { prefix: 'aao_worker' } } });
const prefixInput = screen.getByDisplayValue('aao_worker');
await user.clear(prefixInput);
expect(onChange).toHaveBeenLastCalledWith('llm.metrics.prefix', undefined);
});
});
@@ -0,0 +1,64 @@
// @vitest-environment jsdom
/**
* Component tests for PathsStorageForm (settings → storage.*).
*
* Focus: text path passthrough (empty → undefined), numeric serialization
* with defaults (taskUploadMaxSizeMb default 50, trashRetentionDays default
* 30), and ENV-override gating that disables the worktree field.
*/
import '../../test/dom-setup';
import { beforeAll, describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import { PathsStorageForm } from './PathsStorageForm';
beforeAll(async () => {
await i18n.changeLanguage('en');
});
function render(config: any, overriddenByEnv: Record<string, boolean> = {}, onChange = vi.fn()) {
renderWithProviders(
<PathsStorageForm config={config} onChange={onChange} overriddenByEnv={overriddenByEnv} />,
);
return onChange;
}
describe('PathsStorageForm', () => {
it('renders existing storage values and numeric defaults', () => {
render({ storage: { worktreeDir: '/srv/worktrees' } });
expect(screen.getByDisplayValue('/srv/worktrees')).toBeInTheDocument();
// defaults appear when unset
expect(screen.getByDisplayValue('50')).toBeInTheDocument(); // taskUploadMaxSizeMb default
expect(screen.getByDisplayValue('30')).toBeInTheDocument(); // trashRetentionDays default
});
it('writes storage.customPiecesDir, undefined when cleared', async () => {
const user = userEvent.setup();
const onChange = render({ storage: { customPiecesDir: '/old' } });
const input = screen.getByDisplayValue('/old');
await user.clear(input);
expect(onChange).toHaveBeenLastCalledWith('storage.customPiecesDir', undefined);
});
it('serializes trashRetentionDays as a Number', () => {
const onChange = render({ storage: {} });
// default render shows 30
const input = screen.getByDisplayValue('30');
fireEvent.change(input, { target: { value: '7' } });
expect(onChange).toHaveBeenCalledWith('storage.trashRetentionDays', 7);
});
it('disables the worktree field when overridden by env', () => {
render({ storage: { worktreeDir: '/srv/worktrees' } }, { 'storage.worktreeDir': true });
const input = screen.getByDisplayValue('/srv/worktrees') as HTMLInputElement;
expect(input.disabled).toBe(true);
});
it('supports the legacy flat worktreeDir env override key', () => {
render({ storage: { worktreeDir: '/srv/worktrees' } }, { worktreeDir: true });
const input = screen.getByDisplayValue('/srv/worktrees') as HTMLInputElement;
expect(input.disabled).toBe(true);
});
});
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
/**
* Component tests for PushNotificationsForm (settings → notifications.push.*).
*
* Focus: enable checkbox gating, URL/text passthrough (empty → undefined),
* and numeric serialization for payloadMaxBytes / queueConcurrency.
*/
import '../../test/dom-setup';
import { beforeAll, describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import { PushNotificationsForm } from './PushNotificationsForm';
beforeAll(async () => {
await i18n.changeLanguage('en');
});
function render(config: any, onChange = vi.fn()) {
renderWithProviders(
<PushNotificationsForm config={config} onChange={onChange} overriddenByEnv={{}} />,
);
return onChange;
}
describe('PushNotificationsForm', () => {
it('renders with realistic existing push config', () => {
render({
notifications: {
push: { enabled: true, vapidSubject: 'https://maestro.example.com/', queueConcurrency: 8 },
},
});
expect(screen.getByDisplayValue('https://maestro.example.com/')).toBeInTheDocument();
const enable = screen.getByLabelText('Enable Web Push') as HTMLInputElement;
expect(enable.checked).toBe(true);
});
it('defaults to disabled when push config is absent', () => {
render({});
const enable = screen.getByLabelText('Enable Web Push') as HTMLInputElement;
expect(enable.checked).toBe(false);
});
it('toggles the enable switch', async () => {
const user = userEvent.setup();
const onChange = render({});
await user.click(screen.getByLabelText('Enable Web Push'));
expect(onChange).toHaveBeenCalledWith('notifications.push.enabled', true);
});
it('writes vapidSubject text, undefined when cleared', async () => {
const user = userEvent.setup();
const onChange = render({ notifications: { push: { vapidSubject: 'mailto:[email protected]' } } });
const input = screen.getByDisplayValue('mailto:[email protected]');
await user.clear(input);
expect(onChange).toHaveBeenLastCalledWith('notifications.push.vapidSubject', undefined);
});
it('serializes payloadMaxBytes to a Number', () => {
const onChange = render({});
const numberInputs = screen.getAllByRole('spinbutton');
// first number field is payloadMaxBytes
fireEvent.change(numberInputs[0], { target: { value: '3072' } });
expect(onChange).toHaveBeenCalledWith('notifications.push.payloadMaxBytes', 3072);
});
});
@@ -32,6 +32,7 @@ export function RulesTable({ rules, movementNames, onChange, disabled = false }:
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">rules</label>
{rules.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm mb-2">
<thead>
<tr className="text-xs text-slate-500">
@@ -80,6 +81,7 @@ export function RulesTable({ rules, movementNames, onChange, disabled = false }:
))}
</tbody>
</table>
</div>
)}
{!disabled && (
<button
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
/**
* Component tests for SafetyForm (settings batch A).
*
* Focus: number coercion (text input -> Number), boolean checkbox toggles,
* the bash-sandbox <select>, the nested historySummarization.* path, and the
* "enabled !== false" default-on semantics for history summarization.
*
* i18n is NOT initialized in the test env, so react-i18next's t() returns the
* raw key — fine for behavior tests (we assert on roles/values, not labels).
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { SafetyForm } from './SafetyForm';
function render(config: any = { safety: {} }) {
const onChange = vi.fn();
renderWithProviders(<SafetyForm config={config} onChange={onChange} overriddenByEnv={{}} />);
return { onChange };
}
function renderStateful(config: any = { safety: {} }) {
const onChange = vi.fn();
const utils = renderStatefulForm(SafetyForm, config, { onChangeSpy: onChange });
return { onChange, ...utils };
}
describe('SafetyForm', () => {
it('renders with empty safety config (defaults visible)', () => {
render({ safety: {} });
// Max Iterations default 200 shown in the first number field.
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
expect(numbers[0]).toHaveValue(200);
// bashSandbox select defaults to 'auto'
expect(screen.getByRole('combobox')).toHaveValue('auto');
});
it('coerces a typed number field to a Number via onChange', async () => {
const { onChange, getConfig } = renderStateful({ safety: { maxIterations: 200 } });
const numbers = screen.getAllByRole('spinbutton');
await userEvent.clear(numbers[0]);
await userEvent.type(numbers[0], '7');
// Live config reflects a real (numeric) value, not a string.
expect(getConfig().safety.maxIterations).toBe(7);
expect(onChange).toHaveBeenCalledWith('safety.maxIterations', expect.any(Number));
});
it('serializes promptGuardRatio as undefined when cleared (empty -> undefined)', async () => {
const { onChange } = render({ safety: { promptGuardRatio: 0.8 } });
// promptGuardRatio is the 4th number input.
const numbers = screen.getAllByRole('spinbutton');
await userEvent.clear(numbers[3]);
expect(onChange).toHaveBeenLastCalledWith('safety.promptGuardRatio', undefined);
});
it('toggles bashUnrestricted checkbox to a boolean', async () => {
const { onChange } = render({ safety: { bashUnrestricted: false } });
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
// bashUnrestricted is the first checkbox.
await userEvent.click(checkboxes[0]);
expect(onChange).toHaveBeenCalledWith('safety.bashUnrestricted', true);
});
it('writes the nested historySummarization path and treats enabled as default-on', async () => {
// enabled defaults to true (checked) when undefined: checked={enabled !== false}.
const { onChange } = render({ safety: {} });
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
// history enabled is the 3rd checkbox (bashUnrestricted, bashAllowNetwork, historyEnabled).
const historyToggle = checkboxes[2];
expect(historyToggle).toBeChecked();
await userEvent.click(historyToggle);
expect(onChange).toHaveBeenCalledWith('safety.historySummarization.enabled', false);
});
it('changes the bash sandbox select value', async () => {
const { onChange } = render({ safety: { bashSandbox: 'auto' } });
await userEvent.selectOptions(screen.getByRole('combobox'), 'always');
expect(onChange).toHaveBeenCalledWith('safety.bashSandbox', 'always');
});
});
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
/**
* Component tests for SearchFilterForm (settings → searchFilter.*).
*
* Focus: blocked-patterns StringArrayEditor add, and the four auto-block
* checkboxes mapping to searchFilter.autoBlock.<key>.
*/
import '../../test/dom-setup';
import { beforeAll, describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import { SearchFilterForm } from './SearchFilterForm';
beforeAll(async () => {
await i18n.changeLanguage('en');
});
function render(config: any, onChange = vi.fn()) {
renderWithProviders(
<SearchFilterForm config={config} onChange={onChange} overriddenByEnv={{}} />,
);
return onChange;
}
describe('SearchFilterForm', () => {
it('renders existing blocked patterns and auto-block state', () => {
render({
searchFilter: {
blockedPatterns: ['secret\\.internal'],
autoBlock: { privateIp: true, email: false },
},
});
expect(screen.getByText('secret\\.internal')).toBeInTheDocument();
const privateIp = screen.getByLabelText('Private IP') as HTMLInputElement;
expect(privateIp.checked).toBe(true);
const email = screen.getByLabelText('Email address') as HTMLInputElement;
expect(email.checked).toBe(false);
});
it('adds a blocked pattern via the StringArrayEditor', async () => {
const user = userEvent.setup();
const onChange = render({ searchFilter: { blockedPatterns: ['a'] } });
const input = screen.getByPlaceholderText('regex pattern');
await user.type(input, 'b\\.c');
await user.keyboard('{Enter}');
expect(onChange).toHaveBeenCalledWith('searchFilter.blockedPatterns', ['a', 'b\\.c']);
});
it('toggles each auto-block checkbox to the correct nested path', async () => {
const user = userEvent.setup();
const onChange = render({ searchFilter: {} });
await user.click(screen.getByLabelText('Phone number'));
expect(onChange).toHaveBeenCalledWith('searchFilter.autoBlock.phone', true);
await user.click(screen.getByLabelText('Internal domain'));
expect(onChange).toHaveBeenCalledWith('searchFilter.autoBlock.internalDomain', true);
});
});
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
/**
* Component tests for ServerTlsForm (settings → server.tls.*).
*
* Focus: port number serialization (parseInt, NaN → undefined), the
* comma-separated HTTP-redirect-port list (single → number, many → array,
* empty → undefined), and the enable-HTTPS checkbox round-trip.
*
* Note: these forms are controlled by an external `config` prop, but our test
* `onChange` is a mock that never writes back, so the input's value never
* advances across keystrokes. To exercise multi-character serialization we use
* `fireEvent.change` to deliver the whole value in a single change event (which
* is exactly the one transformation the component performs).
*/
import '../../test/dom-setup';
import { beforeAll, describe, it, expect, vi } from 'vitest';
import { screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
import { ServerTlsForm } from './ServerTlsForm';
beforeAll(async () => {
await i18n.changeLanguage('en');
});
function render(config: any, onChange = vi.fn()) {
renderWithProviders(
<ServerTlsForm config={config} onChange={onChange} overriddenByEnv={{}} />,
);
return onChange;
}
// The redirect-port field placeholder contains a double space; match loosely.
const redirectPortField = () => screen.getByPlaceholderText(/9080.*or 80/);
describe('ServerTlsForm', () => {
it('renders existing server.tls values', () => {
render({
server: {
port: 9876,
tls: { enabled: true, certFile: '/etc/ssl/server.pem', httpRedirectPort: [80, 9876] },
},
});
expect(screen.getByDisplayValue('9876')).toBeInTheDocument();
expect(screen.getByDisplayValue('/etc/ssl/server.pem')).toBeInTheDocument();
// array redirect ports rendered joined
expect(screen.getByDisplayValue('80, 9876')).toBeInTheDocument();
});
it('serializes server.port via parseInt', () => {
const onChange = render({ server: { tls: {} } });
const portInput = screen.getByPlaceholderText('9876');
fireEvent.change(portInput, { target: { value: '8443' } });
expect(onChange).toHaveBeenCalledWith('server.port', 8443);
});
it('parses a single redirect port to a number', () => {
const onChange = render({ server: { tls: {} } });
fireEvent.change(redirectPortField(), { target: { value: '80' } });
expect(onChange).toHaveBeenLastCalledWith('server.tls.httpRedirectPort', 80);
});
it('parses a comma-separated redirect port list to an array', () => {
const onChange = render({ server: { tls: {} } });
fireEvent.change(redirectPortField(), { target: { value: '80, 9876' } });
expect(onChange).toHaveBeenLastCalledWith('server.tls.httpRedirectPort', [80, 9876]);
});
it('clears redirect port to undefined when no valid number is present', () => {
const onChange = render({ server: { tls: { httpRedirectPort: 80 } } });
fireEvent.change(redirectPortField(), { target: { value: 'abc' } });
expect(onChange).toHaveBeenLastCalledWith('server.tls.httpRedirectPort', undefined);
});
it('toggles the Serve-over-HTTPS checkbox', async () => {
const user = userEvent.setup();
const onChange = render({ server: { tls: {} } });
const enableCheckbox = screen.getByLabelText('Serve over HTTPS');
await user.click(enableCheckbox);
expect(onChange).toHaveBeenCalledWith('server.tls.enabled', true);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* SkillsForm.tsx — Settings > Skills tab
* SkillsForm.tsx — Skills panel (mounted under User Folder, not Settings)
*
* Two-column list + detail layout for browsing, creating, editing, and
* deleting agent skills. Supports installing skills from a URL.
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
/**
* Component tests for ToolsForm (settings batch A).
*
* This is the legacy grab-bag Tools form (Web / Vision / X / Maps / ... sub-tabs).
* Focus: tab switching shows the right fields, number coercion on timeouts, the
* userScriptsEnabled checkbox boolean, and that visibleTabs narrows the tab set.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { ToolsForm } from './ToolsForm';
function render(config: any = { tools: {} }, visibleTabs?: any) {
const onChange = vi.fn();
renderWithProviders(
<ToolsForm config={config} onChange={onChange} overriddenByEnv={{}} visibleTabs={visibleTabs} />,
);
return { onChange };
}
function renderStateful(config: any = { tools: {} }) {
const onChange = vi.fn();
// ToolsForm ignores overriddenByEnv; renderStatefulForm passes {} which is fine.
const utils = renderStatefulForm(ToolsForm as any, config, { onChangeSpy: onChange });
return { onChange, ...utils };
}
describe('ToolsForm', () => {
it('renders the Web tab first and writes searxngUrl as a string', async () => {
const { getConfig } = renderStateful({ tools: {} });
// SearXNG URL is the first text input on the Web tab; SSRF editor confirms tab.
expect(screen.getByPlaceholderText('hostname or IP address')).toBeInTheDocument();
const textInputs = screen.getAllByRole('textbox') as HTMLInputElement[];
await userEvent.type(textInputs[0], 'http://searx.local');
expect(getConfig().tools.searxngUrl).toBe('http://searx.local');
});
it('coerces the webfetch timeout to a number', async () => {
const { getConfig } = renderStateful({ tools: { webfetchTimeout: 30 } });
const numbers = screen.getAllByRole('spinbutton');
await userEvent.clear(numbers[0]);
await userEvent.type(numbers[0], '45');
expect(getConfig().tools.webfetchTimeout).toBe(45);
expect(typeof getConfig().tools.webfetchTimeout).toBe('number');
});
it('switches to the X tab and shows X-specific fields', async () => {
render({ tools: {} });
// The X tab label.
await userEvent.click(screen.getByRole('button', { name: 'X / Twitter' }));
expect(screen.getByText('X Auth Token')).toBeInTheDocument();
expect(screen.getByText('X ct0')).toBeInTheDocument();
});
it('toggles userScriptsEnabled to a boolean on the User Folder tab', async () => {
const { onChange } = render({ tools: { userScriptsEnabled: false } });
await userEvent.click(screen.getByRole('button', { name: 'User Folder' }));
const checkbox = screen.getByRole('checkbox');
await userEvent.click(checkbox);
expect(onChange).toHaveBeenCalledWith('tools.userScriptsEnabled', true);
});
it('respects visibleTabs to narrow the visible sub-tabs', () => {
render({ tools: {} }, ['web']);
expect(screen.getByRole('button', { name: 'Web' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'X / Twitter' })).toBeNull();
});
});
@@ -0,0 +1,57 @@
// @vitest-environment jsdom
/**
* Component tests for EmptyState's three render branches:
* 1. compact → title + optional hint + optional action (no steps list)
* 2. hint-only (hint, no description, no onCreateTask) → centered title+hint
* 3. default → title + optional description + the 3-step onboarding list +
* optional "create task" button.
* Uses the real i18n instance (auto-initialized on import) for the layout ns.
*/
import '../../test/dom-setup';
import '../../i18n'; // initializes i18next so useTranslation('layout') resolves
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { EmptyState } from './EmptyState';
describe('EmptyState', () => {
it('compact branch: shows title + hint + action, no steps list', () => {
render(
<EmptyState compact title="Nothing here" hint="add something" action={<span>act</span>} />,
);
expect(screen.getByText('Nothing here')).toBeInTheDocument();
expect(screen.getByText('add something')).toBeInTheDocument();
expect(screen.getByText('act')).toBeInTheDocument();
// compact branch never renders the numbered onboarding list
expect(screen.queryByRole('listitem')).toBeNull();
});
it('hint-only branch: title + hint, still no steps list', () => {
render(<EmptyState title="Pick a thread" hint="choose from the left" />);
expect(screen.getByText('Pick a thread')).toBeInTheDocument();
expect(screen.getByText('choose from the left')).toBeInTheDocument();
expect(screen.queryByRole('listitem')).toBeNull();
});
it('default branch: renders title, description, and the 3 onboarding steps', () => {
render(<EmptyState title="Welcome" description="this is the description" />);
expect(screen.getByText('Welcome')).toBeInTheDocument();
expect(screen.getByText('this is the description')).toBeInTheDocument();
// 3 numbered steps from the layout i18n namespace
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
it('default branch: renders + wires the create button when onCreateTask given', async () => {
const onCreateTask = vi.fn();
const user = userEvent.setup();
render(<EmptyState title="Welcome" onCreateTask={onCreateTask} />);
const btn = screen.getByRole('button');
await user.click(btn);
expect(onCreateTask).toHaveBeenCalledTimes(1);
});
it('default branch: no create button when onCreateTask omitted', () => {
render(<EmptyState title="Welcome" description="d" />);
expect(screen.queryByRole('button')).toBeNull();
});
});
@@ -1,8 +0,0 @@
export function LoadingSpinner({ label = 'Loading...' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 p-4 text-[13px] text-slate-500">
<div className="w-4 h-4 border-2 border-slate-200 border-t-accent rounded-full animate-spin" />
{label}
</div>
);
}
@@ -0,0 +1,40 @@
// @vitest-environment jsdom
/**
* Component tests for StatChip: a label/value pill whose value styling branches
* on the value type (number → big extrabold, string → smaller truncating text)
* unless an explicit valueClassName overrides both.
*/
import '../../test/dom-setup';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StatChip } from './StatChip';
describe('StatChip', () => {
it('renders the label and value', () => {
render(<StatChip label="Tasks" value={42} />);
expect(screen.getByText('Tasks')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
});
it('uses the number styling branch for a numeric value', () => {
render(<StatChip label="Count" value={7} />);
const valueEl = screen.getByText('7');
expect(valueEl).toHaveClass('text-lg', 'font-extrabold');
expect(valueEl).not.toHaveClass('truncate');
});
it('uses the string styling branch (truncate) for a string value', () => {
render(<StatChip label="Owner" value="alice" />);
const valueEl = screen.getByText('alice');
expect(valueEl).toHaveClass('truncate');
expect(valueEl).not.toHaveClass('text-lg');
});
it('honors an explicit valueClassName over the type-based branch', () => {
render(<StatChip label="X" value={99} valueClassName="custom-value-class" />);
const valueEl = screen.getByText('99');
expect(valueEl).toHaveClass('custom-value-class');
// type-based classes are not applied when overridden
expect(valueEl).not.toHaveClass('text-lg');
});
});
@@ -0,0 +1,47 @@
// @vitest-environment jsdom
/**
* Component tests for StatusBadge: it maps a job status string to a tone
* (bg/fg) and a human label. Branching lives in `statusTone` (per-status
* colors) and `formatStatusLabel` (known label vs. raw passthrough), both of
* which the badge wires straight into the rendered <span>'s style + text.
*/
import '../../test/dom-setup';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StatusBadge } from './StatusBadge';
import { statusTone } from '../../lib/utils';
describe('StatusBadge', () => {
it('renders the human label for a known status', () => {
render(<StatusBadge status="succeeded" />);
// COLUMN_LABELS maps succeeded -> "Done"
expect(screen.getByText('Done')).toBeInTheDocument();
});
it('falls back to the raw status string for an unknown status', () => {
render(<StatusBadge status="totally_unknown" />);
expect(screen.getByText('totally_unknown')).toBeInTheDocument();
});
it('applies the running tone colors as inline style', () => {
render(<StatusBadge status="running" />);
const tone = statusTone('running');
const badge = screen.getByText('Running');
// jsdom normalizes color values; assert via the style object the component set.
expect(badge).toHaveStyle({ background: tone.bg, color: tone.fg });
});
it('uses a distinct tone for failed vs succeeded (branching)', () => {
const failed = statusTone('failed');
const succeeded = statusTone('succeeded');
expect(failed.bg).not.toBe(succeeded.bg);
render(<StatusBadge status="failed" />);
expect(screen.getByText('Failed')).toHaveStyle({ background: failed.bg });
});
it('merges an extra className onto the badge', () => {
render(<StatusBadge status="queued" className="my-extra-class" />);
expect(screen.getByText('Inbox')).toHaveClass('my-extra-class');
});
});
@@ -0,0 +1,22 @@
/**
* Pure-function tests for shouldConfirmWrite.
* No DOM render needed — these run in node env.
*/
import { describe, it, expect } from 'vitest';
import { shouldConfirmWrite } from './app-bridge';
describe('shouldConfirmWrite', () => {
it('returns false when autoApprove=true (always bypass confirm)', () => {
// Even a path that would normally require confirm is bypassed.
expect(shouldConfirmWrite('my-app', 'some/arbitrary/file.txt', true)).toBe(false);
});
it('returns false when path is under output/ (allowlisted, no confirm needed)', () => {
expect(shouldConfirmWrite('my-app', 'output/report.csv', false)).toBe(false);
});
it('returns true when autoApprove=false and path is NOT allowlisted', () => {
// A path not under output/ or apps/{appName}/data/ must trigger a confirm.
expect(shouldConfirmWrite('my-app', 'some/arbitrary/file.txt', false)).toBe(true);
});
});
+65 -28
View File
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fetchSpaceFileContent, fetchSpaceFiles, writeSpaceFile, deleteSpaceFiles, getSpaceFileRawUrl } from '../../api';
import { Trans, useTranslation } from 'react-i18next';
import type { AppFileGateway } from './app-file-gateway';
import {
resolveAppPath,
isWriteWithoutConfirm,
shouldConfirmWrite,
isAppBridgeRequest,
type AppBridgeResponse,
} from './app-bridge';
@@ -34,12 +35,26 @@ import {
*/
interface AppRunnerProps {
spaceId: string;
/**
* File I/O gateway. The authenticated space gateway exposes writeFile/deleteFile;
* the public (read-only) app-share gateway omits them. AppRunner keys the
* write/delete bridge paths off their presence — no separate read-only flag needed
* for I/O. `canWrite` only affects the header copy + whether write confirms are even
* attempted (defaults to true when a writeFile is present).
*/
gateway: AppFileGateway;
/** App folder name under apps/ (e.g. "invoice-gen" for apps/invoice-gen/index.html). */
appName: string;
/** Workspace-relative path to the app's entry HTML (e.g. "apps/foo/index.html"). */
entryPath: string;
onClose: () => void;
/** Explicit read/write hint for the header copy. Defaults to gateway.writeFile presence. */
canWrite?: boolean;
/**
* When true, skip all write/delete confirm dialogs (headless E2E harness only).
* In production this prop is omitted (defaults to false), so behavior is unchanged.
*/
autoApproveWrites?: boolean;
}
interface ConfirmState {
@@ -78,7 +93,7 @@ function injectCsp(html: string): string {
return `${meta}${html}`;
}
function rewriteRelativeAssets(html: string, spaceId: string, appName: string): string {
function rewriteRelativeAssets(html: string, appName: string, rawUrl: (path: string) => string): string {
const appDir = `apps/${appName}`;
return html.replace(/\b(src|href)\s*=\s*(["'])(.*?)\2/gi, (m, attr: string, q: string, url: string) => {
const u = url.trim();
@@ -98,32 +113,37 @@ function rewriteRelativeAssets(html: string, spaceId: string, appName: string):
} catch {
return m; // unsafe relative → leave untouched (will simply fail to load)
}
return `${attr}=${q}${getSpaceFileRawUrl(spaceId, rel)}${q}`;
return `${attr}=${q}${rawUrl(rel)}${q}`;
});
}
export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerProps) {
export function AppRunner({ gateway, appName, entryPath, onClose, canWrite, autoApproveWrites = false }: AppRunnerProps) {
const { t } = useTranslation('spaces');
const iframeRef = useRef<HTMLIFrameElement>(null);
const [srcDoc, setSrcDoc] = useState<string | null>(null);
const [loadError, setLoadError] = useState('');
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
// Fetch + prepare the app HTML once per (spaceId, entryPath).
// 書き込み可否は gateway.writeFile の有無で決まる(read-only gateway は未定義)。
// canWrite は header コピーの上書きヒントのみ(既定は writeFile の存在)。
const writable = canWrite ?? typeof gateway.writeFile === 'function';
// Fetch + prepare the app HTML once per (gateway, entryPath).
useEffect(() => {
let cancelled = false;
setSrcDoc(null);
setLoadError('');
(async () => {
try {
const html = await fetchSpaceFileContent(spaceId, entryPath);
const html = await gateway.fetchContent(entryPath);
if (cancelled) return;
setSrcDoc(injectCsp(rewriteRelativeAssets(html, spaceId, appName)));
setSrcDoc(injectCsp(rewriteRelativeAssets(html, appName, gateway.rawUrl)));
} catch {
if (!cancelled) setLoadError('アプリの読み込みに失敗しました');
if (!cancelled) setLoadError(t('appRunner.loadError'));
}
})();
return () => { cancelled = true; };
}, [spaceId, entryPath, appName]);
}, [gateway, entryPath, appName]);
// Ask the user before a non-allowlisted write. Returns a promise that
// resolves true (approved) / false (denied).
@@ -147,7 +167,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
switch (req.type) {
case 'readFile': {
const path = resolveAppPath(appName, req.path);
const content = await fetchSpaceFileContent(spaceId, path);
const content = await gateway.fetchContent(path);
return { id, ok: true, data: { path, content } };
}
case 'listFiles': {
@@ -157,27 +177,33 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
if (typeof dirRaw === 'string' && dirRaw.trim() !== '') {
dir = resolveAppPath(appName, dirRaw);
}
const r = await fetchSpaceFiles(spaceId, dir);
const r = await gateway.listFiles(dir);
return { id, ok: true, data: { dir, entries: r.entries } };
}
case 'writeFile': {
// read-only gateway(公開共有)には writeFile が無い → 書き込み不可。
if (!gateway.writeFile) return { id, ok: false, error: 'read-only' };
const path = resolveAppPath(appName, req.path);
if (typeof req.content !== 'string') {
return { id, ok: false, error: 'content must be a string' };
}
if (!isWriteWithoutConfirm(appName, path)) {
if (shouldConfirmWrite(appName, path, autoApproveWrites)) {
const approved = await requestWriteConfirm(path);
if (!approved) return { id, ok: false, error: 'write denied by user' };
}
const res = await writeSpaceFile(spaceId, path, { content: req.content });
const res = await gateway.writeFile(path, req.content);
return { id, ok: true, data: res };
}
case 'deleteFile': {
// read-only gateway(公開共有)には deleteFile が無い → 削除不可。
if (!gateway.deleteFile) return { id, ok: false, error: 'read-only' };
const path = resolveAppPath(appName, req.path);
// delete always confirms, regardless of directory.
const approved = await requestWriteConfirm(path);
if (!approved) return { id, ok: false, error: 'delete denied by user' };
const res = await deleteSpaceFiles(spaceId, [path]);
// delete always confirms, unless autoApproveWrites bypasses it.
if (!autoApproveWrites) {
const approved = await requestWriteConfirm(path);
if (!approved) return { id, ok: false, error: 'delete denied by user' };
}
const res = await gateway.deleteFile(path);
return { id, ok: true, data: res };
}
default:
@@ -186,7 +212,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
} catch (e) {
return { id, ok: false, error: e instanceof Error ? e.message : String(e) };
}
}, [spaceId, appName, requestWriteConfirm]);
}, [gateway, appName, requestWriteConfirm, autoApproveWrites]);
// postMessage listener — only trusts messages from OUR iframe's window.
useEffect(() => {
@@ -214,15 +240,21 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
data-testid="app-runner"
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-label={`ワークスペース・アプリ ${title}`}
aria-label={t('appRunner.dialogLabel', { title })}
>
<div className="flex items-center gap-3 border-b border-hairline px-4 py-2">
<span className="text-sm font-medium text-slate-800 dark:text-slate-100">{title}</span>
<span
className="rounded bg-surface px-2 py-0.5 text-2xs text-slate-500"
title="このアプリはワークスペースのファイルにアクセスします(あなたの権限の範囲内)"
title={
writable
? t('appRunner.accessTitle.writable')
: t('appRunner.accessTitle.readonly')
}
>
{writable
? t('appRunner.accessLabel.writable')
: t('appRunner.accessLabel.readonly')}
</span>
<div className="flex-1" />
<button
@@ -231,7 +263,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
onClick={onClose}
className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
>
{t('appRunner.close')}
</button>
</div>
@@ -258,10 +290,15 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
data-testid="app-write-confirm"
className="w-[min(28rem,90vw)] rounded-lg border border-hairline bg-canvas p-4 shadow-xl"
role="alertdialog"
aria-label="書き込み確認"
aria-label={t('appRunner.writeConfirm.label')}
>
<p className="text-sm text-slate-700 dark:text-slate-200">
{title} <code className="rounded bg-surface px-1">{confirm.path}</code>
<Trans
i18nKey="appRunner.writeConfirm.body"
t={t}
values={{ title, path: confirm.path }}
components={{ path: <code className="rounded bg-surface px-1" /> }}
/>
</p>
<div className="mt-4 flex justify-end gap-2">
<button
@@ -270,7 +307,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
onClick={() => handleConfirm(false)}
className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface"
>
{t('appRunner.writeConfirm.deny')}
</button>
<button
type="button"
@@ -278,7 +315,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
onClick={() => handleConfirm(true)}
className="rounded bg-[var(--brand-primary)] px-3 py-1 text-sm text-white hover:opacity-90"
>
{t('appRunner.writeConfirm.allow')}
</button>
</div>
</div>
+3 -1
View File
@@ -17,6 +17,7 @@
* 本コンポーネントはワークスペース統合(タスクページ廃止に向けた操作感寄せ)の一部。
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
export const SPLIT_MIN_LEFT = 320; // チャットの最小幅(px)
export const SPLIT_MIN_RIGHT = 360; // 詳細の最小幅(px)
@@ -67,6 +68,7 @@ interface ChatDetailSplitProps {
}
export function ChatDetailSplit({ left, right, rightVisible, storageKey }: ChatDetailSplitProps) {
const { t } = useTranslation('spaces');
const containerRef = useRef<HTMLDivElement>(null);
const leftPaneRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false);
@@ -172,7 +174,7 @@ export function ChatDetailSplit({ left, right, rightVisible, storageKey }: ChatD
<div
role="separator"
aria-orientation="vertical"
aria-label="チャットと詳細の幅を調整"
aria-label={t('chatDetailSplit.resizeLabel')}
data-testid="chat-detail-resize"
onPointerDown={handlePointerDown}
onDoubleClick={handleReset}
+151 -95
View File
@@ -1,36 +1,26 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries } from '@tanstack/react-query';
import {
fetchCrossSpaceCalendarMonth,
fetchSpaceCalendarDay,
type CrossCalendarSpace,
type CalendarEvent,
} from '../../api';
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
import { localToday, localTzOffset } from '../../lib/localDate';
import {
WEEKDAYS,
localMonth,
shiftMonth,
monthGridDays,
splitWeeks,
fmtRange,
fmtTimeBadge,
layoutWeekBars,
} from '../../lib/calendar';
import { useIsMobile } from '../../hooks/useIsMobile';
import { SwipeableTabs } from '../mobile/SwipeableTabs';
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
/** 'YYYY-MM' of the viewer's local today. */
function localMonth(): string {
return localToday().slice(0, 7);
}
/** month ± delta, as 'YYYY-MM' (calendar arithmetic on the 1st via UTC). */
function shiftMonth(month: string, delta: number): string {
const [y, m] = month.split('-').map(Number);
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
/** All 'YYYY-MM-DD' cells for the month grid, padded to full weeks (Sun-start). */
function monthGridDays(month: string): string[] {
const [y, m] = month.split('-').map(Number);
const first = new Date(Date.UTC(y, m - 1, 1));
const start = shiftDay(first.toISOString().slice(0, 10), -first.getUTCDay());
const days: string[] = [];
for (let i = 0; i < 42; i++) days.push(shiftDay(start, i));
return days;
}
interface CrossSpaceCalendarProps {
/** open a space's detail (switches to the Spaces page). */
onOpenSpace: (spaceId: string) => void;
@@ -39,6 +29,7 @@ interface CrossSpaceCalendarProps {
}
export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalendarProps) {
const { t } = useTranslation('spaces');
const tzOffset = localTzOffset();
const isMobile = useIsMobile();
const [month, setMonth] = useState(localMonth());
@@ -51,8 +42,10 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
const today = localToday();
const days = useMemo(() => monthGridDays(month), [month]);
const weeks = useMemo(() => splitWeeks(days), [days]);
const counts = monthQuery.data?.days ?? {};
const spaces = monthQuery.data?.spaces ?? [];
const events = useMemo(() => monthQuery.data?.events ?? [], [monthQuery.data]);
const spaceById = useMemo(() => {
const m = new Map<string, CrossCalendarSpace>();
for (const s of spaces) m.set(s.id, s);
@@ -61,7 +54,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
const monthLabel = (() => {
const [y, m] = month.split('-');
return `${y}${Number(m)}`;
return t('calendar.monthLabel', { year: y, month: Number(m) });
})();
const grid = (
@@ -73,7 +66,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
data-testid="cross-cal-prev"
onClick={() => setMonth(m => shiftMonth(m, -1))}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
aria-label="前の月"
aria-label={t('calendar.prevMonth')}
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M10 4l-4 4 4 4" /></svg>
</button>
@@ -83,7 +76,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
data-testid="cross-cal-next"
onClick={() => setMonth(m => shiftMonth(m, 1))}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
aria-label="次の月"
aria-label={t('calendar.nextMonth')}
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4l4 4-4 4" /></svg>
</button>
@@ -96,59 +89,103 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
))}
</div>
{/* day cells: one color dot per space with activity that day */}
<div data-testid="cross-cal-grid" className="grid grid-cols-7 gap-1">
{days.map(d => {
const inMonth = d.slice(0, 7) === month;
const bySpace = counts[d];
const activeSpaceIds = bySpace
? Object.keys(bySpace).filter(sid => (bySpace[sid]!.taskCount + bySpace[sid]!.eventCount) > 0)
: [];
const isToday = d === today;
const isSelected = d === selectedDate;
{/* day cells(タスクはスペース色のドット)+ 複数日予定の横棒(週ごと・スペース色) */}
<div data-testid="cross-cal-grid" className="flex flex-col gap-1">
{weeks.map((week, wi) => {
const bars = layoutWeekBars(week, events);
const laneCount = bars.reduce((m, b) => Math.max(m, b.lane + 1), 0);
return (
<button
key={d}
type="button"
data-testid={`cross-cal-day-${d}`}
onClick={() => setSelectedDate(d)}
className={`flex min-h-[3.25rem] flex-col items-stretch gap-1 rounded-md border p-1 text-left transition-colors ${
isSelected
? 'border-[var(--brand-primary)] bg-surface'
: 'border-hairline hover:bg-surface'
} ${inMonth ? '' : 'opacity-40'}`}
>
<span
className={`text-[11px] font-semibold ${
isToday
? 'inline-flex h-5 w-5 items-center justify-center self-start rounded-full bg-[var(--brand-primary)] text-white'
: 'text-slate-600'
}`}
>
{Number(d.slice(8, 10))}
</span>
<span className="flex flex-wrap gap-0.5">
{activeSpaceIds.slice(0, 6).map(sid => {
const sp = spaceById.get(sid);
return (
<div key={wi} className="grid grid-cols-7 gap-1">
{week.map(d => {
const inMonth = d.slice(0, 7) === month;
const bySpace = counts[d];
// ドットは「タスクのある」スペースだけ。予定は下の横棒で表す。
const taskSpaceIds = bySpace
? Object.keys(bySpace).filter(sid => bySpace[sid]!.taskCount > 0)
: [];
const isToday = d === today;
const isSelected = d === selectedDate;
return (
<button
key={d}
type="button"
data-testid={`cross-cal-day-${d}`}
onClick={() => setSelectedDate(d)}
className={`flex min-h-[2.5rem] flex-col items-stretch gap-0.5 rounded-md border p-1 text-left transition-colors ${
isSelected
? 'border-[var(--brand-primary)] bg-surface'
: 'border-hairline hover:bg-surface'
} ${inMonth ? '' : 'opacity-40'}`}
>
<span
key={sid}
data-testid={`cross-cal-dot-${sid}`}
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: sp?.color ?? 'var(--brand-primary)' }}
title={sp?.name ?? sid}
/>
);
})}
{activeSpaceIds.length > 6 && (
<span className="text-[8px] font-bold text-slate-400">+{activeSpaceIds.length - 6}</span>
)}
</span>
</button>
className={`text-[11px] font-semibold ${
isToday
? 'inline-flex h-5 w-5 items-center justify-center self-start rounded-full bg-[var(--brand-primary)] text-white'
: 'text-slate-600'
}`}
>
{Number(d.slice(8, 10))}
</span>
{taskSpaceIds.length > 0 && (
<span className="flex flex-wrap gap-0.5">
{taskSpaceIds.slice(0, 6).map(sid => {
const sp = spaceById.get(sid);
return (
<span
key={sid}
data-testid={`cross-cal-dot-${sid}`}
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: sp?.color ?? 'var(--brand-primary)' }}
title={t('crossCalendar.dotTitle', { name: sp?.name ?? sid, count: bySpace?.[sid]?.taskCount ?? 0 })}
/>
);
})}
{taskSpaceIds.length > 6 && (
<span className="text-[8px] font-bold text-slate-400">+{taskSpaceIds.length - 6}</span>
)}
</span>
)}
</button>
);
})}
{bars.length > 0 && (
<div
className="col-span-7 grid grid-cols-7 gap-x-1 gap-y-0.5 pb-0.5"
style={{ gridTemplateRows: `repeat(${laneCount}, 1.05rem)` }}
>
{bars.map(b => {
const sp = spaceById.get(b.ev.spaceId);
const color = sp?.color ?? 'var(--brand-primary)';
return (
<button
key={b.ev.id}
type="button"
data-testid={`cross-cal-bar-${b.ev.id}`}
title={`${sp?.name ? t('crossCalendar.barTitlePrefix', { name: sp.name }) : ''}${t('crossCalendar.barTitle', { title: b.ev.title, range: fmtRange(b.ev) })}`}
onClick={() => setSelectedDate(week[b.colStart - 1]!)}
style={{
gridColumn: `${b.colStart} / span ${b.colSpan}`,
gridRow: b.lane + 1,
backgroundColor: `color-mix(in srgb, ${color} 22%, transparent)`,
borderLeft: `2px solid ${color}`,
}}
className={`flex items-center overflow-hidden whitespace-nowrap px-1 text-[9px] font-semibold leading-none text-slate-700 transition-opacity hover:opacity-80 dark:text-slate-100 ${
b.continuesLeft ? 'rounded-l-none' : 'rounded-l'
} ${b.continuesRight ? 'rounded-r-none' : 'rounded-r'}`}
>
<span className="truncate">
{b.continuesLeft ? '◀ ' : b.ev.time ? `${b.ev.time} ` : ''}{b.ev.title}{b.continuesRight ? ' ▶' : ''}
</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
{monthQuery.isError && <p className="text-xs text-red-600"></p>}
{monthQuery.isError && <p className="text-xs text-red-600">{t('calendar.fetchError')}</p>}
</div>
);
@@ -157,6 +194,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
date={selectedDate}
tzOffset={tzOffset}
counts={counts[selectedDate] ?? {}}
events={events}
spaces={spaces}
onOpenSpace={onOpenSpace}
onOpenTask={onOpenTask}
@@ -164,25 +202,25 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
/>
) : (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
{t('crossCalendar.emptyHint')}
</div>
);
// モバイル: 月の左右スワイプで月送り。日を選ぶと下にオーバーレイで日詳細を出す
// モバイル: 上に月グリッド(左右スワイプで月送り)、下に日詳細の上下 2 分割
if (isMobile) {
return (
<div data-testid="cross-calendar" className="flex h-full flex-col overflow-hidden">
<SwipeableTabs
tabs={[shiftMonth(month, -1), month, shiftMonth(month, 1)]}
activeTab={month}
onTabChange={(m) => setMonth(m)}
renderTab={(m) => (m === month ? <div className="p-3 overflow-y-auto h-full">{grid}</div> : <div className="h-full" />)}
/>
{selectedDate && (
<div className="absolute inset-0 z-20 flex flex-col bg-canvas">
{panel}
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
<SwipeableTabs
tabs={[shiftMonth(month, -1), month, shiftMonth(month, 1)]}
activeTab={month}
onTabChange={(m) => setMonth(m)}
renderTab={(m) => (m === month ? <div className="p-3 overflow-y-auto h-full">{grid}</div> : <div className="h-full" />)}
/>
</div>
<div data-testid="cross-cal-mobile-detail" className="h-[45%] min-h-0 shrink-0 overflow-hidden border-t border-hairline">
{panel}
</div>
</div>
);
}
@@ -200,6 +238,7 @@ function CrossDayPanel({
date,
tzOffset,
counts,
events,
spaces,
onOpenSpace,
onOpenTask,
@@ -209,19 +248,33 @@ function CrossDayPanel({
tzOffset: number;
/** {[spaceId]: {taskCount,eventCount}} for this day (from the month aggregate). */
counts: Record<string, { taskCount: number; eventCount: number }>;
/** その月の全イベント(spaceId 付き)。月集計のクリップ外の日でもバーから補完する。 */
events: CalendarEvent[];
spaces: CrossCalendarSpace[];
onOpenSpace: (spaceId: string) => void;
onOpenTask: (taskId: number) => void;
onClose: () => void;
}) {
const { t: tr } = useTranslation('spaces');
// 月集計 counts は当月内にクリップされるため、月境界をまたぐ予定の隣月側の日を
// 選ぶと活動なし扱いになってしまう。選択日に重なる予定の spaceId を直接補完する。
const eventSpaceIds = useMemo(() => {
const ids = new Set<string>();
for (const ev of events) {
const end = ev.endDate && ev.endDate > ev.date ? ev.endDate : ev.date;
if (ev.date <= date && date <= end) ids.add(ev.spaceId);
}
return ids;
}, [events, date]);
// Only fetch the per-space day detail for spaces that have activity on this
// day — avoids a second cross endpoint and avoids N empty fetches.
const activeSpaces = useMemo(
() => spaces.filter(s => {
const c = counts[s.id];
return c && (c.taskCount + c.eventCount) > 0;
return (c && (c.taskCount + c.eventCount) > 0) || eventSpaceIds.has(s.id);
}),
[spaces, counts],
[spaces, counts, eventSpaceIds],
);
const dayQueries = useQueries({
@@ -240,7 +293,7 @@ function CrossDayPanel({
data-testid="cross-cal-day-close"
onClick={onClose}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 hover:bg-surface hover:text-slate-700"
aria-label="閉じる"
aria-label={tr('crossCalendar.close')}
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
@@ -248,7 +301,7 @@ function CrossDayPanel({
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-4">
{activeSpaces.length === 0 && (
<p className="text-xs text-slate-400"></p>
<p className="text-xs text-slate-400">{tr('crossCalendar.noActiveSpaces')}</p>
)}
{activeSpaces.map((sp, i) => {
const day = dayQueries[i]?.data;
@@ -274,7 +327,7 @@ function CrossDayPanel({
onClick={() => onOpenTask(t.id)}
className="flex w-full items-center gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-left transition-colors hover:bg-surface"
>
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || `タスク #${t.id}`}</span>
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || tr('crossCalendar.taskFallback', { id: t.id })}</span>
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{t.status ?? t.state}</span>
</button>
</li>
@@ -285,17 +338,20 @@ function CrossDayPanel({
{/* 予定 */}
{day && day.events.length > 0 && (
<ul className="mt-1 space-y-1">
{day.events.map(ev => (
{day.events.map((ev: CalendarEvent) => (
<li
key={ev.id}
data-testid={`cross-cal-event-${ev.id}`}
className="flex items-start gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5"
>
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
{ev.time ?? '終日'}
{fmtTimeBadge(ev)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
{ev.endDate && ev.endDate > ev.date && (
<div className="text-[10px] font-medium text-amber-700 dark:text-amber-300">🗓 {fmtRange(ev)}</div>
)}
{ev.description && <div className="truncate text-[11px] text-slate-500">{ev.description}</div>}
</div>
</li>
@@ -304,7 +360,7 @@ function CrossDayPanel({
)}
{day && day.tasks.length === 0 && day.events.length === 0 && (
<p className="text-xs text-slate-400"></p>
<p className="text-xs text-slate-400">{tr('crossCalendar.noItems')}</p>
)}
</section>
);
+14 -18
View File
@@ -11,18 +11,13 @@
* spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
fetchInvitePreview,
acceptSpaceInvite,
type InvitePreview,
type SpaceInviteRole,
} from '../../api';
const ROLE_LABEL: Record<SpaceInviteRole, string> = {
editor: '編集者',
viewer: '閲覧者',
};
type State =
| { kind: 'loading' }
| { kind: 'unauthorized' }
@@ -40,6 +35,7 @@ function Shell({ children }: { children: React.ReactNode }) {
}
export function JoinSpace({ token }: { token: string }) {
const { t } = useTranslation('spaces');
const [state, setState] = useState<State>({ kind: 'loading' });
const [joining, setJoining] = useState(false);
const [error, setError] = useState('');
@@ -83,16 +79,16 @@ export function JoinSpace({ token }: { token: string }) {
const returnTo = encodeURIComponent(window.location.pathname);
return (
<Shell>
<h1 className="mb-2 text-base font-semibold text-slate-900"></h1>
<h1 className="mb-2 text-base font-semibold text-slate-900">{t('joinSpace.title')}</h1>
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
{t('joinSpace.unauthorized.body')}
</p>
<a
href={`/auth/login?returnTo=${returnTo}`}
data-testid="join-space-login"
className="inline-block rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-fg hover:bg-accent-deep"
>
{t('joinSpace.unauthorized.login')}
</a>
</Shell>
);
@@ -101,12 +97,12 @@ export function JoinSpace({ token }: { token: string }) {
if (state.kind === 'invalid') {
return (
<Shell>
<h1 className="mb-2 text-base font-semibold text-slate-900"></h1>
<h1 className="mb-2 text-base font-semibold text-slate-900">{t('joinSpace.invalid.title')}</h1>
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
{t('joinSpace.invalid.body')}
</p>
<a href="/ui/" className="text-[13px] text-slate-600 underline hover:text-slate-900">
{t('joinSpace.invalid.home')}
</a>
</Shell>
);
@@ -115,11 +111,11 @@ export function JoinSpace({ token }: { token: string }) {
// ok
return (
<Shell>
<h1 className="mb-1 text-base font-semibold text-slate-900"></h1>
<h1 className="mb-1 text-base font-semibold text-slate-900">{t('joinSpace.title')}</h1>
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
<span className="font-semibold text-slate-900">{state.preview.spaceTitle}</span>
<span className="font-semibold text-slate-900">{ROLE_LABEL[state.preview.role]}</span>
<span className="font-semibold text-slate-900">{state.preview.spaceTitle}</span>{t('joinSpace.ok.joinAs.lead')}
<span className="font-semibold text-slate-900">{t('joinSpace.ok.joinAs.roleQuoted', { role: t(`joinSpace.role.${state.preview.role}`) })}</span>
{t('joinSpace.ok.joinAs.trail')}
</p>
{error && <div className="mb-3 text-[13px] text-red-600">{error}</div>}
<div className="flex justify-center gap-2">
@@ -127,7 +123,7 @@ export function JoinSpace({ token }: { token: string }) {
href="/ui/"
className="rounded-md border border-hairline px-4 py-2 text-sm text-slate-700 hover:bg-surface"
>
{t('joinSpace.ok.cancel')}
</a>
<button
type="button"
@@ -136,7 +132,7 @@ export function JoinSpace({ token }: { token: string }) {
disabled={joining}
className="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-fg hover:bg-accent-deep disabled:opacity-50"
>
{joining ? '参加中…' : '参加する'}
{joining ? t('joinSpace.ok.joining') : t('joinSpace.ok.join')}
</button>
</div>
</Shell>
+163 -12
View File
@@ -1,8 +1,17 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchSpaceFiles, fetchSpaceFileContent } from '../../api';
import { Trans, useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceFiles,
fetchSpaceFileContent,
getAppShareLink,
createAppShareLink,
revokeAppShareLink,
} from '../../api';
import { deriveAppList, type WorkspaceApp } from './app-bridge';
import { AppRunner } from './AppRunner';
import { createSpaceGateway } from './app-file-gateway';
import { buildAppShareDisplayUrl } from './appShareUrl';
/**
* SpaceApps — the workspace "アプリ" tab.
@@ -17,7 +26,8 @@ import { AppRunner } from './AppRunner';
* NETWORK / SECURITY: the apps themselves run under AppRunner's opaque-origin
* sandbox with `connect-src 'none'` — this tab only discovers + launches them.
*/
export function SpaceApps({ spaceId }: { spaceId: string }) {
export function SpaceApps({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
const { t } = useTranslation('spaces');
const [appToRun, setAppToRun] = useState<WorkspaceApp | null>(null);
const appsQuery = useQuery({
@@ -32,15 +42,15 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
<div data-testid="space-apps" className="flex flex-col gap-3 p-4">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-500">
{t('apps.heading')}
</p>
<button
type="button"
data-testid="space-apps-refresh"
onClick={() => void appsQuery.refetch()}
disabled={appsQuery.isFetching}
title="再読み込み"
aria-label="再読み込み"
title={t('apps.refresh')}
aria-label={t('apps.refresh')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:opacity-50"
>
<svg className={`h-3.5 w-3.5 ${appsQuery.isFetching ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
@@ -51,7 +61,7 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
</div>
{appsQuery.isError && (
<p className="text-xs text-red-600"></p>
<p className="text-xs text-red-600">{t('apps.fetchError')}</p>
)}
{!appsQuery.isLoading && !appsQuery.isError && apps.length === 0 && (
@@ -59,12 +69,16 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
data-testid="space-apps-empty"
className="rounded-lg border border-dashed border-hairline bg-surface p-6 text-center text-sm text-slate-500"
>
<p className="font-medium text-slate-600"></p>
<p className="font-medium text-slate-600">{t('apps.empty.title')}</p>
<p className="mt-1.5 leading-relaxed">
{t('apps.empty.body')}
</p>
<p className="mt-1.5 text-2xs text-slate-400">
<code className="rounded bg-canvas px-1 py-0.5">apps/</code> <code className="rounded bg-canvas px-1 py-0.5">apps/&#123;&#125;/index.html</code>
<Trans
t={t}
i18nKey="apps.empty.location"
components={{ code: <code className="rounded bg-canvas px-1 py-0.5" /> }}
/>
</p>
</div>
)}
@@ -106,8 +120,10 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" stroke="none">
<path d="M5 3.5v9l7-4.5z" />
</svg>
{t('apps.open')}
</button>
{canManage && <AppShareControls spaceId={spaceId} appName={app.name} />}
</div>
))}
</div>
@@ -115,7 +131,7 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
{appToRun && (
<AppRunner
spaceId={spaceId}
gateway={createSpaceGateway(spaceId)}
appName={appToRun.name}
entryPath={appToRun.entryPath}
onClose={() => setAppToRun(null)}
@@ -182,3 +198,138 @@ async function loadWorkspaceApps(spaceId: string): Promise<WorkspaceApp[]> {
name => indexMap.get(name)?.manifest ?? null,
);
}
/**
* AppShareControls — 1 アプリの公開共有リンク管理(canManage のみ表示)。
*
* 現在のリンク状態を取得し、未発行なら「リンクを作成」、発行済みなら公開 URL の表示・
* コピー・失効を提供する。発行/失効は react-query mutation。公開リンクは read-only・
* ログイン不要・apps/{app}/+output/ に封じ込められる旨を注意書きで明示する。
*/
function AppShareControls({ spaceId, appName }: { spaceId: string; appName: string }) {
const { t } = useTranslation('spaces');
const qc = useQueryClient();
const [copied, setCopied] = useState(false);
const queryKey = ['app-share-link', spaceId, appName];
const linkQuery = useQuery({
queryKey,
queryFn: () => getAppShareLink(spaceId, appName),
staleTime: 10_000,
});
const createMut = useMutation({
mutationFn: () => createAppShareLink(spaceId, appName),
onSuccess: (data) => {
qc.setQueryData(queryKey, { token: data.token, shareUrl: data.shareUrl, revokedAt: null });
},
});
const revokeMut = useMutation({
mutationFn: () => revokeAppShareLink(spaceId, appName),
onSuccess: () => {
qc.setQueryData(queryKey, { token: null, revokedAt: new Date().toISOString() });
setCopied(false);
},
});
const link = linkQuery.data;
const shareUrl = link?.token && link.shareUrl ? link.shareUrl : null;
const displayUrl = shareUrl ? buildAppShareDisplayUrl(window.location.origin, shareUrl) : null;
const copy = async () => {
if (!displayUrl) return;
try {
await navigator.clipboard.writeText(displayUrl);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
// clipboard 不可(権限なし等)。表示済み URL から手動コピーできるので無視。
}
};
const onRevoke = () => {
if (window.confirm(t('appShare.revokeConfirm', { appName }))) {
revokeMut.mutate();
}
};
const busy = createMut.isPending || revokeMut.isPending;
return (
<div
data-testid={`space-app-share-${appName}`}
className="mt-1 border-t border-hairline pt-2"
>
{linkQuery.isLoading ? (
<p className="text-2xs text-slate-400">{t('appShare.checking')}</p>
) : displayUrl ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<span
className="inline-flex items-center gap-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-2xs font-semibold text-emerald-600 dark:text-emerald-400"
data-testid={`space-app-share-badge-${appName}`}
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
</svg>
{t('appShare.issued')}
</span>
</div>
<input
type="text"
readOnly
value={displayUrl}
data-testid={`space-app-share-url-${appName}`}
onFocus={(e) => e.currentTarget.select()}
className="w-full truncate rounded border border-hairline bg-canvas px-2 py-1 font-mono text-2xs text-slate-600"
/>
<div className="flex items-center gap-1.5">
<button
type="button"
data-testid={`space-app-share-copy-${appName}`}
onClick={() => void copy()}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface"
>
{copied ? t('appShare.copied') : t('appShare.copyUrl')}
</button>
<button
type="button"
data-testid={`space-app-share-revoke-${appName}`}
onClick={onRevoke}
disabled={busy}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-950/30"
>
{t('appShare.revoke')}
</button>
</div>
<p className="text-2xs leading-relaxed text-amber-600">
<Trans
t={t}
i18nKey="appShare.warning"
components={{ code: <code className="rounded bg-canvas px-1" />, strong: <strong /> }}
/>
</p>
</div>
) : (
<div className="flex flex-col gap-1">
<button
type="button"
data-testid={`space-app-share-create-${appName}`}
onClick={() => createMut.mutate()}
disabled={busy}
className="inline-flex h-7 items-center gap-1 self-start rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
</svg>
{createMut.isPending ? t('appShare.creating') : t('appShare.create')}
</button>
{(createMut.isError || revokeMut.isError) && (
<p className="text-2xs text-red-600">{t('appShare.opError')}</p>
)}
</div>
)}
</div>
);
}
+31 -36
View File
@@ -15,6 +15,7 @@
* ときだけ管理コントロールを出す。判定できない場合でも 403 はトーストで処理。
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
listBrowserSessionProfiles, deleteBrowserSessionProfile, testBrowserSessionProfile,
@@ -28,14 +29,6 @@ import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
const STATUS_LABEL: Record<BrowserSessionProfile['status'], string> = {
pending: '保存待ち',
active: '有効',
expired: '期限切れ',
revoked: '失効',
error: 'エラー',
};
const STATUS_CLASS: Record<BrowserSessionProfile['status'], string> = {
pending: 'bg-slate-200 text-slate-700',
active: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
@@ -45,9 +38,10 @@ const STATUS_CLASS: Record<BrowserSessionProfile['status'], string> = {
};
function StatusPill({ status }: { status: BrowserSessionProfile['status'] }) {
const { t } = useTranslation('spaces');
return (
<span className={`inline-flex items-center rounded px-2 py-0.5 text-2xs font-medium ${STATUS_CLASS[status]}`}>
{STATUS_LABEL[status]}
{t(`browser.status.${status}`)}
</span>
);
}
@@ -57,6 +51,7 @@ function errMsg(e: unknown): string {
}
export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const auth = useAuthState();
const qc = useQueryClient();
@@ -81,12 +76,12 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
const delSess = useMutation({
mutationFn: (id: number) => deleteBrowserSessionProfile(id, spaceId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }),
onError: (e) => showToast?.(`セッションの削除に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('browser.toast.sessionDeleteFailed', { msg: errMsg(e) }), 'error'),
});
const testSess = useMutation({
mutationFn: (id: number) => testBrowserSessionProfile(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }),
onError: (e) => showToast?.(`セッションの検証に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('browser.toast.sessionTestFailed', { msg: errMsg(e) }), 'error'),
});
const [adding, setAdding] = useState(false);
@@ -102,12 +97,12 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
const delMacro = useMutation({
mutationFn: (name: string) => deleteFolderFile('browser-macros', name, spaceId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-macros', spaceId] }),
onError: (e) => showToast?.(`削除に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('browser.toast.deleteFailed', { msg: errMsg(e) }), 'error'),
});
const delRecording = useMutation({
mutationFn: (name: string) => deleteFolderFile('recordings', name, spaceId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-recordings', spaceId] }),
onError: (e) => showToast?.(`削除に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('browser.toast.deleteFailed', { msg: errMsg(e) }), 'error'),
});
// ファイル内容プレビュー(マクロ・録画共通)。
@@ -117,7 +112,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
const content = await getFolderFile(subdir, name, spaceId);
setPreview({ name, content });
} catch (e) {
showToast?.(`内容の取得に失敗しました: ${errMsg(e)}`, 'error');
showToast?.(t('browser.toast.contentFetchFailed', { msg: errMsg(e) }), 'error');
}
}
@@ -127,7 +122,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
{/* ── セッション ── */}
<section data-testid="space-browser-sessions">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-800"></h2>
<h2 className="text-base font-semibold text-slate-800">{t('browser.sessions.heading')}</h2>
{canManage && (
<button
type="button"
@@ -135,22 +130,21 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
onClick={() => setAdding(true)}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:bg-accent-deep"
>
{t('browser.sessions.add')}
</button>
)}
</div>
<p className="mb-3 text-xs text-slate-500">
{t('browser.sessions.intro')}
</p>
{sessLoading && <div className="text-xs text-slate-500"></div>}
{sessLoading && <div className="text-xs text-slate-500">{t('common:loading')}</div>}
<div className="divide-y divide-hairline rounded-md border border-hairline">
{profiles.length === 0 && !sessLoading && (
<div className="px-3 py-6 text-center text-xs text-slate-400">
<div></div>
{canManage && <div className="mt-1"></div>}
<div>{t('browser.sessions.empty')}</div>
{canManage && <div className="mt-1">{t('browser.sessions.emptyHint')}</div>}
</div>
)}
{profiles.map(p => {
@@ -163,7 +157,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
<StatusPill status={p.status} />
{!usable && (
<span className="inline-flex items-center rounded bg-slate-200 px-2 py-0.5 text-2xs font-medium text-slate-600">
{t('browser.sessions.creatorOnly')}
</span>
)}
</div>
@@ -171,7 +165,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
{p.lastError && <div className="truncate text-2xs text-rose-600">{p.lastError}</div>}
{!usable && (
<div className="text-2xs text-slate-400">
{t('browser.sessions.creatorOnlyHint')}
</div>
)}
</div>
@@ -183,16 +177,16 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
disabled={testSess.isPending}
className="rounded px-2 py-1 text-xs text-slate-700 hover:bg-surface hover:text-slate-900 disabled:opacity-50"
>
{t('browser.sessions.test')}
</button>
)}
{canManage && (
<button
type="button"
onClick={() => { if (confirm(`${p.label}」を削除しますか?`)) delSess.mutate(p.id); }}
onClick={() => { if (confirm(t('browser.deleteConfirm', { name: p.label }))) delSess.mutate(p.id); }}
className="rounded px-2 py-1 text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-800 dark:hover:bg-rose-500/15 dark:hover:text-rose-300"
>
{t('common:delete')}
</button>
)}
</div>
@@ -204,28 +198,28 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
{/* ── マクロ ── */}
<FolderSection
title="ブラウザマクロ"
title={t('browser.macros.title')}
testid="space-browser-macros"
subdir="browser-macros"
query={macros}
emptyText="このワークスペースにはまだブラウザマクロがありません。"
hint="エージェントがブラウザ操作を記録すると、このワークスペースのマクロとして保存されます。"
emptyText={t('browser.macros.empty')}
hint={t('browser.macros.hint')}
canManage={canManage}
onView={(name) => openPreview('browser-macros', name)}
onDelete={(name) => { if (confirm(`${name}」を削除しますか?`)) delMacro.mutate(name); }}
onDelete={(name) => { if (confirm(t('browser.deleteConfirm', { name }))) delMacro.mutate(name); }}
/>
{/* ── 録画 ── */}
<FolderSection
title="録画"
title={t('browser.recordings.title')}
testid="space-browser-recordings"
subdir="recordings"
query={recordings}
emptyText="このワークスペースにはまだ録画がありません。"
hint="ブラウザ操作の記録(録画)がこのワークスペースのフォルダに保存されます。"
emptyText={t('browser.recordings.empty')}
hint={t('browser.recordings.hint')}
canManage={canManage}
onView={(name) => openPreview('recordings', name)}
onDelete={(name) => { if (confirm(`${name}」を削除しますか?`)) delRecording.mutate(name); }}
onDelete={(name) => { if (confirm(t('browser.deleteConfirm', { name }))) delRecording.mutate(name); }}
/>
</div>
@@ -253,12 +247,13 @@ interface FolderSectionProps {
}
function FolderSection({ title, testid, query, emptyText, hint, canManage, onView, onDelete }: FolderSectionProps) {
const { t } = useTranslation('spaces');
const files = query.data ?? [];
return (
<section data-testid={testid}>
<h2 className="mb-2 text-base font-semibold text-slate-800">{title}</h2>
<p className="mb-3 text-xs text-slate-500">{hint}</p>
{query.isLoading && <div className="text-xs text-slate-500"></div>}
{query.isLoading && <div className="text-xs text-slate-500">{t('common:loading')}</div>}
<div className="divide-y divide-hairline rounded-md border border-hairline">
{files.length === 0 && !query.isLoading && (
<div className="px-3 py-6 text-center text-xs text-slate-400">{emptyText}</div>
@@ -279,7 +274,7 @@ function FolderSection({ title, testid, query, emptyText, hint, canManage, onVie
onClick={() => onDelete(f.name)}
className="ml-2 shrink-0 rounded px-2 py-1 text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-800 dark:hover:bg-rose-500/15 dark:hover:text-rose-300"
>
{t('common:delete')}
</button>
)}
</div>
+98 -132
View File
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceCalendarMonth,
@@ -8,60 +9,40 @@ import {
deleteCalendarEvent,
fetchSpaceFileContent,
getSpaceFileRawUrl,
getSpaceFileOfficePreviewUrl,
getSpaceTrustedHtmlUrl,
type CalendarEvent,
} from '../../api';
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
import { localToday, localTzOffset } from '../../lib/localDate';
import {
WEEKDAYS,
localMonth,
shiftMonth,
monthGridDays,
splitWeeks,
fmtRange,
fmtTimeBadge,
layoutWeekBars,
} from '../../lib/calendar';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
import { useIsMobile } from '../../hooks/useIsMobile';
import { FilePreview } from '../files/FilePreview';
import type { OfficePreviewDescriptor } from '../files/FilePreview';
import { FileTypeIcon } from '../files/FileTypeIcon';
import { SwipeableTabs } from '../mobile/SwipeableTabs';
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
/** 'YYYY-MM' of the viewer's local today. */
function localMonth(): string {
return localToday().slice(0, 7);
}
/** month ± delta, as 'YYYY-MM' (calendar arithmetic on the 1st via UTC). */
function shiftMonth(month: string, delta: number): string {
const [y, m] = month.split('-').map(Number);
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
}
/** All 'YYYY-MM-DD' cells for the month grid, padded to full weeks (Sun-start). */
function monthGridDays(month: string): string[] {
const [y, m] = month.split('-').map(Number);
const first = new Date(Date.UTC(y, m - 1, 1));
const start = shiftDay(first.toISOString().slice(0, 10), -first.getUTCDay());
const days: string[] = [];
// 6 weeks always covers any month layout (max 31 days + 6 lead).
for (let i = 0; i < 42; i++) days.push(shiftDay(start, i));
return days;
}
function fmtSize(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}MB`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}KB`;
return `${n}B`;
}
/** 'YYYY-MM-DD' → 'M/D'(月グリッドのバー・期間表示用)。 */
function fmtMonthDay(d: string): string {
return `${Number(d.slice(5, 7))}/${Number(d.slice(8, 10))}`;
}
/** 予定の期間を短く: 単日は 'M/D'、複数日は 'M/DM/D'。 */
function fmtRange(ev: CalendarEvent): string {
const end = ev.endDate && ev.endDate > ev.date ? ev.endDate : null;
return end ? `${fmtMonthDay(ev.date)}${fmtMonthDay(end)}` : fmtMonthDay(ev.date);
}
// ── カレンダーの表示フィルター(タスク / 変更ファイル / 予定)─────────────
type CalFilters = { tasks: boolean; files: boolean; events: boolean };
const FILTER_DEFS: ReadonlyArray<{ key: keyof CalFilters; label: string; icon: string }> = [
{ key: 'tasks', label: 'タスク', icon: '💬' },
{ key: 'files', label: '変更ファイル', icon: '📄' },
{ key: 'events', label: '予定', icon: '📌' },
const FILTER_DEFS: ReadonlyArray<{ key: keyof CalFilters; labelKey: string; icon: string }> = [
{ key: 'tasks', labelKey: 'calendar.filter.tasks', icon: '💬' },
{ key: 'files', labelKey: 'calendar.filter.files', icon: '📄' },
{ key: 'events', labelKey: 'calendar.filter.events', icon: '📌' },
];
const FILTERS_STORAGE_KEY = 'spaceCalendarFilters';
function loadFilters(): CalFilters {
@@ -75,49 +56,6 @@ function loadFilters(): CalFilters {
return { tasks: true, files: true, events: true };
}
/** 1 週(7 日)にかかるイベントを、横棒の lane(重ならない行)に割り付ける。 */
interface WeekBar {
ev: CalendarEvent;
colStart: number; // 17
colSpan: number;
lane: number; // 0-based の積み上げ行
continuesLeft: boolean;
continuesRight: boolean;
}
function layoutWeekBars(weekDays: string[], events: CalendarEvent[]): WeekBar[] {
const weekStart = weekDays[0]!;
const weekEnd = weekDays[6]!;
const segs = events
.map((ev) => {
const evEnd = ev.endDate && ev.endDate > ev.date ? ev.endDate : ev.date;
if (evEnd < weekStart || ev.date > weekEnd) return null;
const segStart = ev.date < weekStart ? weekStart : ev.date;
const segEnd = evEnd > weekEnd ? weekEnd : evEnd;
const colStart = weekDays.indexOf(segStart) + 1;
const colEnd = weekDays.indexOf(segEnd) + 1;
return {
ev,
colStart,
colSpan: colEnd - colStart + 1,
continuesLeft: ev.date < weekStart,
continuesRight: evEnd > weekEnd,
};
})
.filter((s): s is Omit<WeekBar, 'lane'> => s !== null)
// 長い棒・早い開始を優先して上の lane に積む
.sort((a, b) => b.colSpan - a.colSpan || a.colStart - b.colStart || a.ev.id - b.ev.id);
const laneEnds: number[] = []; // lane ごとの「最後に埋まった列」
const bars: WeekBar[] = [];
for (const s of segs) {
let lane = laneEnds.findIndex((end) => end < s.colStart);
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0); }
laneEnds[lane] = s.colStart + s.colSpan - 1;
bars.push({ ...s, lane });
}
return bars;
}
interface SpaceCalendarProps {
spaceId: string;
/** open the chat for a task created in this space (switches to the chat tab). */
@@ -127,6 +65,7 @@ interface SpaceCalendarProps {
}
export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarProps) {
const { t } = useTranslation('spaces');
const tzOffset = localTzOffset();
const isMobile = useIsMobile();
const [month, setMonth] = useState(localMonth());
@@ -148,16 +87,12 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
const today = localToday();
const days = useMemo(() => monthGridDays(month), [month]);
const weeks = useMemo(() => {
const out: string[][] = [];
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
return out;
}, [days]);
const weeks = useMemo(() => splitWeeks(days), [days]);
const counts = monthQuery.data?.days ?? {};
const events = useMemo(() => monthQuery.data?.events ?? [], [monthQuery.data]);
const monthLabel = (() => {
const [y, m] = month.split('-');
return `${y}${Number(m)}`;
return t('calendar.monthLabel', { year: y, month: Number(m) });
})();
const grid = (
@@ -169,7 +104,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
data-testid="space-cal-prev"
onClick={() => setMonth(m => shiftMonth(m, -1))}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
aria-label="前の月"
aria-label={t('calendar.prevMonth')}
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M10 4l-4 4 4 4" /></svg>
</button>
@@ -179,7 +114,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
data-testid="space-cal-next"
onClick={() => setMonth(m => shiftMonth(m, 1))}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
aria-label="次の月"
aria-label={t('calendar.nextMonth')}
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4l4 4-4 4" /></svg>
</button>
@@ -202,7 +137,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
: 'border-hairline bg-canvas text-slate-400 line-through'
}`}
>
<span aria-hidden>{f.icon}</span>{f.label}
<span aria-hidden>{f.icon}</span>{t(f.labelKey)}
</button>
);
})}
@@ -249,7 +184,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
{Number(d.slice(8, 10))}
</span>
{filters.tasks && c?.taskCount ? (
<span className="self-start rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={`タスク ${c.taskCount}`}>
<span className="self-start rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={t('calendar.taskCount', { count: c.taskCount })}>
💬{c.taskCount}
</span>
) : null}
@@ -266,7 +201,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
key={b.ev.id}
type="button"
data-testid={`space-cal-bar-${b.ev.id}`}
title={`${b.ev.title}${fmtRange(b.ev)}`}
title={t('calendar.barTitle', { title: b.ev.title, range: fmtRange(b.ev) })}
onClick={() => setSelectedDate(week[b.colStart - 1]!)}
style={{ gridColumn: `${b.colStart} / span ${b.colSpan}`, gridRow: b.lane + 1 }}
className={`flex items-center overflow-hidden whitespace-nowrap bg-amber-100 px-1 text-[9px] font-semibold leading-none text-amber-800 transition-colors hover:bg-amber-200 dark:bg-amber-500/25 dark:text-amber-200 ${
@@ -284,7 +219,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
);
})}
</div>
{monthQuery.isError && <p className="text-xs text-red-600"></p>}
{monthQuery.isError && <p className="text-xs text-red-600">{t('calendar.fetchError')}</p>}
</div>
);
@@ -301,7 +236,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
/>
) : (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
{t('calendar.emptyHint')}
</div>
);
@@ -339,6 +274,7 @@ interface DayPanelPreview {
imageSrc: string;
markdownImageBaseUrl?: string;
trustedHtmlUrl?: string;
office?: OfficePreviewDescriptor;
}
function DayPanel({
@@ -360,6 +296,7 @@ function DayPanel({
onOpenChat: (taskId: number) => void;
onClose: () => void;
}) {
const { t } = useTranslation('spaces');
const qc = useQueryClient();
const dayQuery = useQuery({
queryKey: ['spaceCalendarDay', spaceId, date, tzOffset],
@@ -376,6 +313,16 @@ function DayPanel({
const handlePreview = useCallback(async (filePath: string, name: string) => {
try {
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
setPreview({
name,
content: '',
imageSrc: '',
office: { kind, url: getSpaceFileOfficePreviewUrl(spaceId, filePath), downloadUrl: getSpaceFileRawUrl(spaceId, filePath) },
});
return;
}
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
const imageSrc = getSpaceFileRawUrl(spaceId, filePath);
const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined;
@@ -405,7 +352,7 @@ function DayPanel({
data-testid="space-cal-day-close"
onClick={onClose}
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 hover:bg-surface hover:text-slate-700"
aria-label="閉じる"
aria-label={t('calendar.close')}
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
@@ -415,25 +362,25 @@ function DayPanel({
{/* タスク */}
{filters.tasks && (
<section>
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500"></h4>
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.tasks')}</h4>
{day && day.tasks.length > 0 ? (
<ul className="space-y-1">
{day.tasks.map(t => (
<li key={t.id}>
{day.tasks.map(task => (
<li key={task.id}>
<button
type="button"
data-testid={`space-cal-task-${t.id}`}
onClick={() => onOpenChat(t.id)}
data-testid={`space-cal-task-${task.id}`}
onClick={() => onOpenChat(task.id)}
className="flex w-full items-center gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-left transition-colors hover:bg-surface"
>
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || `タスク #${t.id}`}</span>
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{t.status ?? t.state}</span>
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{task.title || t('calendar.taskFallback', { id: task.id })}</span>
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{task.status ?? task.state}</span>
</button>
</li>
))}
</ul>
) : (
<p className="text-xs text-slate-400"></p>
<p className="text-xs text-slate-400">{t('calendar.day.noTasks')}</p>
)}
</section>
)}
@@ -441,7 +388,7 @@ function DayPanel({
{/* 変更ファイル */}
{filters.files && (
<section>
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500"></h4>
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.changedFiles')}</h4>
{day && day.files.length > 0 ? (
<ul className="space-y-1">
{day.files.map(f => (
@@ -462,7 +409,7 @@ function DayPanel({
))}
</ul>
) : (
<p className="text-xs text-slate-400"></p>
<p className="text-xs text-slate-400">{t('calendar.day.noChangedFiles')}</p>
)}
</section>
)}
@@ -471,7 +418,7 @@ function DayPanel({
{filters.events && (
<section>
<div className="mb-1.5 flex items-center justify-between">
<h4 className="text-2xs font-bold uppercase tracking-wider text-slate-500"></h4>
<h4 className="text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.events')}</h4>
{canEdit && !showAdd && !editing && (
<button
type="button"
@@ -479,7 +426,7 @@ function DayPanel({
onClick={() => setShowAdd(true)}
className="rounded-md border border-hairline bg-canvas px-2 py-0.5 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface hover:text-slate-900"
>
{t('calendar.day.addEvent')}
</button>
)}
</div>
@@ -503,7 +450,7 @@ function DayPanel({
className="flex items-start gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5"
>
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
{ev.time ?? '終日'}
{fmtTimeBadge(ev)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
@@ -511,7 +458,7 @@ function DayPanel({
<div className="text-[10px] font-medium text-amber-700 dark:text-amber-300">🗓 {fmtRange(ev)}</div>
)}
{ev.description && <div className="truncate text-[11px] text-slate-500">{ev.description}</div>}
{ev.createdBy === 'agent' && <div className="text-[10px] text-slate-400">🤖 </div>}
{ev.createdBy === 'agent' && <div className="text-[10px] text-slate-400">{t('calendar.day.agent')}</div>}
</div>
{canEdit && (
<div className="flex shrink-0 items-center gap-1">
@@ -520,7 +467,7 @@ function DayPanel({
data-testid={`space-cal-event-edit-${ev.id}`}
onClick={() => { setShowAdd(false); setEditing(ev); }}
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-surface hover:text-slate-700"
aria-label="編集"
aria-label={t('calendar.day.editEvent')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M11 2l3 3-8 8H3v-3z" /></svg>
</button>
@@ -528,12 +475,12 @@ function DayPanel({
type="button"
data-testid={`space-cal-event-delete-${ev.id}`}
onClick={async () => {
if (!window.confirm('この予定を削除しますか?')) return;
if (!window.confirm(t('calendar.day.deleteEventConfirm'))) return;
await deleteCalendarEvent(spaceId, ev.id);
invalidate();
}}
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
aria-label="削除"
aria-label={t('common:delete')}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" /></svg>
</button>
@@ -543,7 +490,7 @@ function DayPanel({
))}
</ul>
) : (
!showAdd && <p className="text-xs text-slate-400"></p>
!showAdd && <p className="text-xs text-slate-400">{t('calendar.day.noEvents')}</p>
)}
</section>
)}
@@ -556,6 +503,7 @@ function DayPanel({
imageSrc={preview.imageSrc}
markdownImageBaseUrl={preview.markdownImageBaseUrl}
trustedHtmlUrl={preview.trustedHtmlUrl}
office={preview.office}
onClose={() => setPreview(null)}
/>
)}
@@ -576,17 +524,25 @@ function EventForm({
onCancel: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation('spaces');
const [title, setTitle] = useState(event?.title ?? '');
const [date, setDate] = useState(event?.date ?? defaultDate);
const [endDate, setEndDate] = useState(event?.endDate ?? '');
const [time, setTime] = useState(event?.time ?? '');
const [endTime, setEndTime] = useState(event?.endTime ?? '');
const [description, setDescription] = useState(event?.description ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const submit = useCallback(async () => {
if (!title.trim()) { setError('タイトルを入力してください。'); return; }
if (endDate && endDate < date) { setError('終了日は開始日以降にしてください。'); return; }
if (!title.trim()) { setError(t('calendar.form.titleRequired')); return; }
if (endDate && endDate < date) { setError(t('calendar.form.endAfterStart')); return; }
// 終了時刻は開始時刻があるときだけ。単日は開始以降のみ(複数日は終了日側の時刻なので順序不問)。
const effEndTime = time && endTime ? endTime : null;
const isMultiDay = !!(endDate && endDate > date);
if (effEndTime && !isMultiDay && effEndTime < time) {
setError(t('calendar.form.endTimeAfterStart')); return;
}
setSaving(true);
setError('');
try {
@@ -595,6 +551,7 @@ function EventForm({
// 終了日が開始日より後のときだけ複数日。それ以外は単日(null)に正規化。
endDate: endDate && endDate > date ? endDate : null,
time: time ? time : null,
endTime: effEndTime,
title: title.trim(),
description: description ? description : null,
};
@@ -602,11 +559,11 @@ function EventForm({
else await createCalendarEvent(spaceId, payload);
onSaved();
} catch (e) {
setError((e as Error)?.message ?? '保存に失敗しました。');
setError((e as Error)?.message ?? t('calendar.form.saveFailed'));
} finally {
setSaving(false);
}
}, [title, date, endDate, time, description, event, spaceId, onSaved]);
}, [title, date, endDate, time, endTime, description, event, spaceId, onSaved]);
return (
<div data-testid="space-cal-add-event" className="mb-2 space-y-2 rounded-md border border-hairline bg-surface p-2.5">
@@ -615,11 +572,11 @@ function EventForm({
data-testid="space-cal-event-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="予定のタイトル"
placeholder={t('calendar.form.titlePlaceholder')}
className="w-full rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
<div className="flex items-center gap-2">
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500"></label>
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.start')}</label>
<input
type="date"
data-testid="space-cal-event-date"
@@ -636,7 +593,7 @@ function EventForm({
/>
</div>
<div className="flex items-center gap-2">
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500"></label>
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.end')}</label>
<input
type="date"
data-testid="space-cal-event-end-date"
@@ -645,21 +602,30 @@ function EventForm({
onChange={e => setEndDate(e.target.value)}
className="flex-1 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
{endDate && (
<button
type="button"
onClick={() => setEndDate('')}
className="shrink-0 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-2xs text-slate-500 hover:bg-surface"
>
</button>
)}
<input
type="time"
data-testid="space-cal-event-end-time"
value={endTime}
disabled={!time}
title={!time ? t('calendar.form.endTimeHint') : undefined}
onChange={e => setEndTime(e.target.value)}
className="w-28 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{endDate && (
<button
type="button"
onClick={() => setEndDate('')}
className="self-start rounded-md border border-hairline bg-canvas px-2 py-1 text-2xs text-slate-500 hover:bg-surface"
>
{t('calendar.form.backToSingleDay')}
</button>
)}
<textarea
data-testid="space-cal-event-description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="メモ(任意)"
placeholder={t('calendar.form.notePlaceholder')}
rows={2}
className="w-full resize-none rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
@@ -671,7 +637,7 @@ function EventForm({
disabled={saving}
className="rounded-md border border-hairline bg-canvas px-2.5 py-1 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
>
{t('common:cancel')}
</button>
<button
type="button"
@@ -680,7 +646,7 @@ function EventForm({
disabled={saving}
className="rounded-md bg-accent px-2.5 py-1 text-2xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
>
{event ? '更新' : '追加'}
{event ? t('calendar.form.update') : t('calendar.form.add')}
</button>
</div>
</div>
+389 -191
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSpaces, useUpdateSpace, useArchiveSpace } from '../../hooks/useSpaces';
import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces';
import { useLocalTaskList } from '../../hooks/useTaskList';
import { useLocalTask, useLocalTaskComments } from '../../hooks/useTaskDetail';
import { useTaskOperations } from '../../hooks/useTaskOperations';
@@ -19,8 +19,10 @@ import type { DetailTabId, SortMode, StatusColumn } from '../../lib/urlState';
import { filterTasksForSpace } from '../../lib/spaceTasks';
import { filterAndSortTasks, groupTasksByStatus, statusCounts, totalTaskCount } from '../../lib/taskFilter';
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
import { workspaceDirRole } from '../../lib/workspaceDirs';
import { FilterBar } from '../list/FilterBar';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
import type { OfficePreviewDescriptor } from '../files/FilePreview';
import { CreateTaskDialog } from '../create/CreateTaskDialog';
import { LocalTaskListItem } from '../list/TaskListItem';
import { ChatPane } from '../chat/ChatPane';
@@ -30,13 +32,19 @@ import { SchedulesPage } from '../../pages/SchedulesPage';
import { SpaceApps } from './SpaceApps';
import { useAuthState } from '../../App';
import { SkeletonChatPane } from '../shared/Skeleton';
import { EmptyState } from '../shared/EmptyState';
import { SwipeableTabs } from '../mobile/SwipeableTabs';
import { FilePreview } from '../files/FilePreview';
import { FileTileGrid } from '../files/FileTileGrid';
import { FileDetailList } from '../files/FileDetailList';
import { useFileView } from '../../hooks/useFileView';
import { FileBreadcrumb } from '../files/FileBreadcrumb';
import { FileActions, FileSelectionBar, FileDropzone } from '../files/FileToolbar';
import { MoveTargetDialog } from '../files/MoveTargetDialog';
import { resolveMoves } from '../../lib/fileMove';
import { FileActions, FileSelectionBar, FileDropzone, FileViewToggle, FileSortMenu, type FileSort } from '../files/FileToolbar';
import { filesToBase64 } from '../../lib/fileBase64';
import { AppRunner } from './AppRunner';
import { createSpaceGateway } from './app-file-gateway';
import { detectAppEntry } from './app-bridge';
import { ChatDetailSplit } from './ChatDetailSplit';
import { OutputPreviewProvider } from '../../lib/output-preview-context';
@@ -47,13 +55,27 @@ import {
fetchSpaceFiles,
fetchSpaceFileContent,
getSpaceFileRawUrl,
getSpaceFileOfficePreviewUrl,
getSpaceTrustedHtmlUrl,
uploadSpaceFiles,
deleteSpaceFiles,
createSpaceFolder,
moveSpaceFile,
downloadSpaceFilesZip,
type CreateLocalTaskInput,
type LocalFileEntry,
type Space,
} from '../../api';
import { SpaceFormDialog } from './SpaceFormDialog';
/** Filter state for the space chat list. Persisted in the URL by App (urlState)
* so it survives tab switches, reloads and bookmarks. */
export interface SpaceChatFilter {
search: string;
status: 'all' | StatusColumn;
sort: SortMode;
scope: TaskScope;
}
interface SpaceDetailProps {
spaceId?: string;
@@ -62,11 +84,14 @@ interface SpaceDetailProps {
onSelectSpaceTask: (id: number) => void;
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
onOpenTask: (id: number) => void;
chatFilter: SpaceChatFilter;
onChatFilterChange: (next: Partial<SpaceChatFilter>) => void;
}
type SpaceTab = 'chat' | 'files' | 'apps' | 'calendar' | 'schedules' | 'settings';
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpaceDetailProps) {
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) {
const { t } = useTranslation('spaces');
const { data: spaces } = useSpaces();
const [tab, setTab] = useState<SpaceTab>('chat');
const containerRef = useRef<HTMLDivElement>(null);
@@ -98,8 +123,11 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
if (!spaceId || !space) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-slate-500">
<div className="flex h-full">
<EmptyState
title={t('detail.empty.title')}
hint={t('detail.empty.hint')}
/>
</div>
);
}
@@ -118,8 +146,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface px-4 py-3`}>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
<SpaceHeaderTitle
spaceId={spaceId}
title={space.title}
space={space}
canManage={canManage}
/>
{space.kind === 'case' && (
@@ -136,12 +163,12 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
{/* Tabs */}
<div className={`${hideOnMobileWhenChatOpen} items-center gap-1 border-b border-hairline px-3`}>
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}></TabButton>
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}></TabButton>
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}></TabButton>
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}></TabButton>
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}></TabButton>
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}></TabButton>
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
</div>
{/* Body */}
@@ -156,6 +183,8 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
spaceTaskId={spaceTaskId}
onSelectSpaceTask={onSelectSpaceTask}
onCreateTask={onCreateTask}
filter={chatFilter}
onFilterChange={onChatFilterChange}
/>
)}
{/* key={spaceId}: ワークスペースを切り替えたら detail のサブツリーを remount し、
@@ -164,7 +193,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
ローカル state は remount しないと前のワークスペースの値が残る(空内容で上書き
されない AGENTS.md の stale 表示など)。 */}
{tab === 'files' && <SpaceFiles key={spaceId} spaceId={spaceId} canManage={canEditFiles} />}
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} />}
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} canManage={canManage} />}
{tab === 'calendar' && (
<SpaceCalendar
key={spaceId}
@@ -181,100 +210,46 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
}
/**
* ヘッダーのワークスペース名。管理権限があればインラインで名前変更できる
* 鉛筆ボタン → 入力 → 保存で PATCH → spaces を invalidate。権限が無ければ
* 単なるタイトル表示。
* ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が
* あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
*/
function SpaceHeaderTitle({
spaceId,
title,
space,
canManage,
}: {
spaceId: string;
title: string;
space: Space;
canManage: boolean;
}) {
const updateSpace = useUpdateSpace();
const { t } = useTranslation('spaces');
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
// 編集を開始したら現在のタイトルを入れ、フォーカスする。
const startEdit = useCallback(() => {
setValue(title);
setEditing(true);
}, [title]);
useEffect(() => {
if (editing) inputRef.current?.select();
}, [editing]);
const save = useCallback(async () => {
const next = value.trim();
if (!next || next === title) { setEditing(false); return; }
try {
await updateSpace.mutateAsync({ id: spaceId, patch: { title: next } });
setEditing(false);
} catch {
// 失敗時は編集状態のまま(入力を消さない)。
}
}, [value, title, spaceId, updateSpace]);
if (!editing) {
return (
<div className="flex min-w-0 flex-1 items-center gap-1">
<h1 className="min-w-0 truncate text-[15px] font-bold text-slate-800">{title}</h1>
{canManage && (
<button
type="button"
data-testid="space-rename"
onClick={startEdit}
title="ワークスペース名を変更"
aria-label="ワークスペース名を変更"
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
</svg>
</button>
)}
</div>
);
}
return (
<div className="flex min-w-0 flex-1 items-center gap-1">
<input
ref={inputRef}
data-testid="space-rename-input"
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { e.preventDefault(); void save(); }
else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); }
}}
className="min-w-0 flex-1 rounded-md border border-hairline bg-canvas px-2 py-1 text-[15px] font-bold text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
/>
<button
type="button"
data-testid="space-rename-save"
onClick={() => void save()}
disabled={updateSpace.isPending}
title="保存"
aria-label="保存"
className="inline-flex h-7 items-center rounded-md bg-accent px-2 text-xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => setEditing(false)}
title="キャンセル"
aria-label="キャンセル"
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
>
</button>
<div className="flex min-w-0 flex-1 items-center gap-2">
<h1 className="shrink truncate text-[15px] font-bold text-slate-800">{space.title}</h1>
{space.description && (
<span
data-testid="space-description"
title={space.description}
className="hidden min-w-0 shrink truncate text-xs text-slate-400 sm:inline"
>
{space.description}
</span>
)}
{canManage && (
<button
type="button"
data-testid="space-edit"
onClick={() => setEditing(true)}
title={t('detail.editSpace')}
aria-label={t('detail.editSpace')}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
</svg>
</button>
)}
{editing && <SpaceFormDialog space={space} onClose={() => setEditing(false)} />}
</div>
);
}
@@ -293,6 +268,7 @@ function SpaceDeleteButton({
title: string;
onDeleted: () => void;
}) {
const { t } = useTranslation('spaces');
const archiveSpace = useArchiveSpace();
const [confirming, setConfirming] = useState(false);
@@ -309,7 +285,7 @@ function SpaceDeleteButton({
if (confirming) {
return (
<div className="flex shrink-0 items-center gap-1">
<span className="hidden text-2xs text-slate-500 sm:inline">{title}</span>
<span className="hidden text-2xs text-slate-500 sm:inline">{t('detail.deletePrompt', { title })}</span>
<button
type="button"
data-testid="space-delete-confirm"
@@ -317,14 +293,14 @@ function SpaceDeleteButton({
disabled={archiveSpace.isPending}
className="inline-flex h-7 items-center rounded-md bg-red-600 px-2 text-xs font-bold text-white transition-colors hover:bg-red-700 disabled:opacity-50"
>
{t('detail.deleteConfirmButton')}
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
>
{t('common:cancel')}
</button>
</div>
);
@@ -335,8 +311,8 @@ function SpaceDeleteButton({
type="button"
data-testid="space-delete"
onClick={() => setConfirming(true)}
title="ワークスペースを削除"
aria-label="ワークスペースを削除"
title={t('detail.deleteSpace')}
aria-label={t('detail.deleteSpace')}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
@@ -352,6 +328,7 @@ function SpaceDeleteButton({
* 重なり表示で先頭から最大 4 名、超過は「+N」。クリックで設定タブ(メンバー管理)へ。
*/
function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage: () => void }) {
const { t } = useTranslation('spaces');
// キーは SpaceMembersPanel と共有する(招待/ロール変更/除去の invalidate が
// ヘッダーのアバター列にも即時反映されるように)。
const { data: members } = useQuery({
@@ -366,7 +343,7 @@ function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage:
const MAX = 4;
const shown = members.slice(0, MAX);
const overflow = members.length - shown.length;
const label = `共有メンバー ${members.length} 名: ${members.map(m => m.name ?? m.userId).join(', ')}`;
const label = t('detail.sharedMembers', { count: members.length, names: members.map(m => m.name ?? m.userId).join(', ') });
return (
<button
@@ -433,25 +410,34 @@ function SpaceChat({
isPersonalSpace,
spaceTaskId,
onSelectSpaceTask,
filter,
onFilterChange,
}: {
spaceId: string;
isPersonalSpace: boolean;
spaceTaskId?: number;
onSelectSpaceTask: (id: number) => void;
onCreateTask: SpaceDetailProps['onCreateTask'];
filter: SpaceChatFilter;
onFilterChange: (next: Partial<SpaceChatFilter>) => void;
}) {
const { t } = useTranslation('spaces');
const qc = useQueryClient();
const auth = useAuthState();
const { data: allTasks } = useLocalTaskList();
const [showCreate, setShowCreate] = useState(false);
const [scope, setScope] = useState<TaskScope>('mine');
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
const setScope = (val: TaskScope) => onFilterChange({ scope: val });
const setSearchQuery = (val: string) => onFilterChange({ search: val });
const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val });
const setSortMode = (val: SortMode) => onFilterChange({ sort: val });
const spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace);
// 自分/他メンバーの切替。共有ワークスペースで「他の人のタスク」が存在するときだけ
// 出す(個人ワークスペースや単独利用では意味がないので隠す)。スコープ分割は Tasks
// ページと同じ filterTasksByScope を再利用する(owner_id null は others 側、という
// 既存規約に合わせる)。SpaceChat は key={spaceId} で remount されるので、スペース
// 切り替えると scope は 'mine' に戻る
// 既存規約に合わせる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の
// onSelectSpace が search/status/sort/scope を明示リセットする(remount 依存ではない)
const userId = auth.mode === 'authenticated' ? auth.user.id : null;
const hasOthersTasks = userId != null && spaceTasks.some(t => t.ownerId !== userId);
const tasks = userId != null && hasOthersTasks
@@ -459,10 +445,8 @@ function SpaceChat({
: spaceTasks;
// 検索・ステータス・ソート(Tasks ページの FilterBar と同じ挙動を共有ヘルパーで再現)。
// scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。
const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState<'all' | StatusColumn>('all');
const [sortMode, setSortMode] = useState<SortMode>('updated');
// scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。state は
// URL 永続化のため SpaceDetail(親)→ App の urlState から流す。
const statusColumns = groupTasksByStatus(tasks);
const counts = statusCounts(statusColumns);
const totalCount = totalTaskCount(statusColumns);
@@ -490,14 +474,14 @@ function SpaceChat({
}`}
>
<div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-hairline">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500"></span>
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">{t('chat.listHeading')}</span>
<button
type="button"
data-testid="space-new-chat-btn"
onClick={() => setShowCreate(true)}
className="shrink-0 rounded-md bg-accent px-2.5 py-1.5 text-xs font-bold text-accent-fg transition-colors hover:opacity-90"
>
{t('chat.new')}
</button>
</div>
@@ -506,7 +490,7 @@ function SpaceChat({
data-testid="space-chat-scope-toggle"
className="flex items-center gap-1 border-b border-hairline px-3 py-1.5"
>
{([['mine', '自分'], ['others', '他のメンバー']] as const).map(([val, label]) => (
{(['mine', 'others'] as const).map((val) => (
<button
key={val}
type="button"
@@ -519,7 +503,7 @@ function SpaceChat({
: 'text-slate-500 hover:bg-surface hover:text-slate-700'
}`}
>
{label}
{t(`chat.scope.${val}`)}
</button>
))}
</div>
@@ -542,14 +526,21 @@ function SpaceChat({
<div className="flex-1 min-h-0 overflow-y-auto p-2">
{totalCount === 0 ? (
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
{hasOthersTasks && scope === 'others'
? '他のメンバーが作成したチャットはありません。'
: 'このワークスペースにはまだチャットがありません。「+ 新規」で始めましょう。'}
</p>
hasOthersTasks && scope === 'others' ? (
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
{t('chat.empty.others')}
</p>
) : (
<div className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
<p>{t('chat.empty.none')}</p>
<p className="mt-1.5 text-2xs text-slate-400 leading-relaxed">
{t('chat.empty.filesHint')}
</p>
</div>
)
) : visibleTasks.length === 0 ? (
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
{t('chat.empty.noMatch')}
</p>
) : (
<div className="space-y-1.5">
@@ -580,7 +571,7 @@ function SpaceChat({
/>
) : (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
{t('chat.selectHint')}
</div>
)}
</div>
@@ -602,6 +593,7 @@ function SpaceChat({
// スペース内インライン会話。App の Tasks 詳細と同じ hook / handler を使い、挙動
// (追加指示送信・キャンセル・ライブ表示)を完全一致させる。
function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => void }) {
const { t: ts } = useTranslation('spaces');
const { toast, showToast } = useToast();
const taskQuery = useLocalTask(taskId, true);
const commentsQuery = useLocalTaskComments(taskId, true);
@@ -637,7 +629,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
// 削除(確認ダイアログ付き)。confirm をキャンセルしたら何もしない。
const confirmAndDelete = useCallback(async () => {
if (!window.confirm('このチャットを削除しますか?この操作は取り消せません。')) return;
if (!window.confirm(ts('conversation.deleteConfirm'))) return;
await handleDelete();
}, [handleDelete]);
@@ -788,7 +780,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 4l-4 4 4 4" />
</svg>
{ts('conversation.backToList')}
</button>
{/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と
@@ -804,10 +796,10 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */}
<span
data-testid="space-chat-visibility-note"
title="このワークスペースのメンバーだけが閲覧できます"
title={ts('conversation.visibilityTitle')}
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-500"
>
🔒
{ts('conversation.visibilityNote')}
</span>
<div className="ml-auto flex items-center gap-1">
@@ -826,8 +818,8 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
type="button"
data-testid="space-chat-delete"
onClick={() => void confirmAndDelete()}
title="削除"
aria-label="削除"
title={ts('common:delete')}
aria-label={ts('common:delete')}
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
@@ -930,6 +922,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
section={previewState.section}
filePath={previewState.filePath}
editable={previewState.editable}
office={previewState.office}
/>
)}
{toast && (
@@ -953,6 +946,7 @@ interface SpacePreviewState {
imageSrc: string;
markdownImageBaseUrl?: string;
trustedHtmlUrl?: string;
office?: OfficePreviewDescriptor;
}
// ソースライブラリの curated 一覧 UI は撤去した(#6)。エージェントが取得した資料は
@@ -964,6 +958,7 @@ interface SpacePreviewState {
// 自分のワークスペースなので削除可)。共有ワークスペースでは SpaceDetail が
// owner/admin 判定を渡す。サーバ側も canEditInSpace で再度ゲートする。
export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
const { t } = useTranslation('spaces');
const [currentPath, setCurrentPath] = useState('');
const [entries, setEntries] = useState<LocalFileEntry[]>([]);
const [loadError, setLoadError] = useState('');
@@ -977,6 +972,9 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [isDeleting, setIsDeleting] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isMoving, setIsMoving] = useState(false);
// 「移動」ダイアログで移動する対象(null = 閉じている)。
const [moveDialogSources, setMoveDialogSources] = useState<string[] | null>(null);
const load = useCallback(async () => {
setIsRefreshing(true);
@@ -986,7 +984,7 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
setLoadError('');
} catch {
setEntries([]);
setLoadError('ファイルの取得に失敗しました');
setLoadError(t('files.loadError'));
} finally {
setIsRefreshing(false);
}
@@ -999,6 +997,16 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
const handlePreview = useCallback(async (filePath: string, name: string) => {
try {
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
setPreview({
name,
content: '',
imageSrc: '',
office: { kind, url: getSpaceFileOfficePreviewUrl(spaceId, filePath), downloadUrl: getSpaceFileRawUrl(spaceId, filePath) },
});
return;
}
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
const imageSrc = getSpaceFileRawUrl(spaceId, filePath);
const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined;
@@ -1013,7 +1021,7 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
}
setPreview({ name, content, imageSrc: '', markdownImageBaseUrl });
} catch {
setLoadError('ファイルの読み込みに失敗しました');
setLoadError(t('files.previewError'));
}
}, [spaceId]);
@@ -1025,14 +1033,88 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
const payload = await filesToBase64(fileList);
const r = await uploadSpaceFiles(spaceId, currentPath, payload);
await load();
setUploadMsg({ text: `${r.uploaded.length} 件のファイルを追加しました`, kind: 'ok' });
setUploadMsg({ text: t('files.uploadedCount', { count: r.uploaded.length }), kind: 'ok' });
} catch (e) {
setUploadMsg({ text: `アップロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
setUploadMsg({ text: t('files.uploadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
} finally {
setIsUploading(false);
}
}, [spaceId, currentPath, load]);
// 現在フォルダに空フォルダを作る。既存スペースに readonly/ を後付けする用途も兼ねる。
const createFolder = useCallback(async () => {
const name = window.prompt(t('files.newFolderPrompt'))?.trim();
if (!name) return;
if (/[\\/]/.test(name)) {
setUploadMsg({ text: t('files.folderNameInvalid'), kind: 'error' });
return;
}
const rel = currentPath ? `${currentPath}/${name}` : name;
setUploadMsg(null);
try {
await createSpaceFolder(spaceId, rel);
await load();
setUploadMsg({ text: t('files.folderCreated', { name }), kind: 'ok' });
} catch (e) {
setUploadMsg({ text: t('files.folderCreateFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
}
}, [spaceId, currentPath, load]);
// ファイル/フォルダのリネーム(move エンドポイント経由)。構造ディレクトリ
// input/output/logs/apps/readonly)はサーバ側で拒否され、UI でも行アクションを出さない。
const renameEntry = useCallback(async (entry: LocalFileEntry) => {
const next = window.prompt(t('files.renamePrompt'), entry.name)?.trim();
if (!next || next === entry.name) return;
if (/[\\/]/.test(next)) {
setUploadMsg({ text: t('files.nameInvalid'), kind: 'error' });
return;
}
const slash = entry.path.lastIndexOf('/');
const parent = slash >= 0 ? entry.path.slice(0, slash) : '';
const to = parent ? `${parent}/${next}` : next;
setUploadMsg(null);
try {
const r = await moveSpaceFile(spaceId, entry.path, to);
await load();
setUploadMsg({ text: t('files.renamed', { from: entry.name, to: r.to.split('/').pop() }), kind: 'ok' });
} catch (e) {
setUploadMsg({ text: t('files.renameFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
}
}, [spaceId, load]);
// ファイル/フォルダをフォルダへ移動する(ドラッグ移動・複数選択移動の共通経路)。
// resolveMoves が no-op / 自己内移動を事前除外し、残りを move エンドポイントへ順に投げる。
// 衝突はサーバが自動リネームするため、ここでは件数だけ報告する。
const moveInto = useCallback(async (sourcePaths: string[], destDir: string) => {
const { moves, skipped } = resolveMoves(sourcePaths, destDir);
if (moves.length === 0) {
if (skipped.length > 0) setUploadMsg({ text: t('files.alreadyThere'), kind: 'ok' });
return;
}
setIsMoving(true);
setUploadMsg(null);
let moved = 0;
let failed = 0;
for (const m of moves) {
try {
await moveSpaceFile(spaceId, m.from, m.to);
moved++;
} catch {
failed++;
}
}
setSelected(new Set());
await load();
const where = destDir ? t('files.moveTargetFolder', { name: destDir.split('/').pop() }) : t('files.moveTargetRoot');
setUploadMsg(
failed > 0
// 一部でも失敗したら赤で示す(成功緑だと失敗を見落とすため)。
? { text: t('files.movedWithFailures', { moved, where, failed }), kind: 'error' }
: { text: t('files.moved', { moved, where }), kind: 'ok' },
);
setIsMoving(false);
}, [spaceId, load]);
// フォルダ移動・ワークスペース切替で選択をクリア(別ディレクトリのパスを持ち越さない)。
useEffect(() => { setSelected(new Set()); }, [currentPath, spaceId]);
@@ -1048,16 +1130,16 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
// ガード + canEditInSpace で再ゲートするが、UI は相対パスだけを送る。
const deleteSelected = useCallback(async (paths: string[]) => {
if (paths.length === 0) return;
if (!window.confirm(`${paths.length} 件のファイルを削除しますか?この操作は取り消せません。`)) return;
if (!window.confirm(t('files.deleteConfirm', { count: paths.length }))) return;
setIsDeleting(true);
setUploadMsg(null);
try {
const r = await deleteSpaceFiles(spaceId, paths);
setSelected(new Set());
await load();
setUploadMsg({ text: `${r.deleted.length} 件のファイルを削除しました`, kind: 'ok' });
setUploadMsg({ text: t('files.deletedCount', { count: r.deleted.length }), kind: 'ok' });
} catch (e) {
setUploadMsg({ text: `削除に失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
setUploadMsg({ text: t('files.deleteFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
} finally {
setIsDeleting(false);
}
@@ -1071,46 +1153,98 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
try {
await downloadSpaceFilesZip(spaceId, paths);
} catch (e) {
setUploadMsg({ text: `ダウンロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
setUploadMsg({ text: t('files.downloadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
} finally {
setIsDownloading(false);
}
}, [spaceId]);
const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : [];
const dirs = entries.filter(e => e.kind === 'directory');
// source/index.jsonl は「ソース」グループのデータ源なので、生のファイル一覧には
// 出さない(ノイズ回避)。source/ フォルダ自体は通常どおりブラウズできる。
const files = entries.filter(
e => e.kind !== 'directory' && !(currentPath === 'source' && e.name === 'index.jsonl'),
const visibleEntries = entries.filter(
e => !(e.kind !== 'directory' && currentPath === 'source' && e.name === 'index.jsonl'),
);
const sorted = [
...dirs.sort((a, b) => a.name.localeCompare(b.name)),
...files.sort((a, b) => a.name.localeCompare(b.name)),
];
const { viewMode, setViewMode, sort, setSort, toggleSort, sortedEntries } = useFileView(visibleEntries);
// アイコン表示のドロップダウン(名前順/新しい順)は共有ソート状態に縮約して乗せる。
// サイズ順は詳細表示の列見出しから操作する(タスク窓の FileBrowser と同方針)。
const menuSort: FileSort = sort.key === 'modified' ? 'newest' : 'name';
const onMenuSort = (s: FileSort) =>
setSort(s === 'newest' ? { key: 'modified', dir: 'desc' } : { key: 'name', dir: 'asc' });
// 選択可能なのはファイルのみ(ディレクトリは削除対象外)。
const selectablePaths = files.map(f => f.path);
// 選択可能なのはファイル+ユーザー作成フォルダ(構造フォルダ=workspaceDirRole 有 は保護で除外)。
const selectablePaths = visibleEntries
.filter(e => e.kind !== 'directory' || workspaceDirRole(e.path, e.name, e.kind) == null)
.map(f => f.path);
const allSelected = selectablePaths.length > 0 && selectablePaths.every(p => selected.has(p));
const selectedInView = selectablePaths.filter(p => selected.has(p));
// リネームボタン(詳細表示・アイコン表示で共有)。構造ディレクトリ
// input/output/logs/apps/readonly)はサーバ側で拒否されるため出さない。
const renameButton = (entry: LocalFileEntry) => {
const isStructural = workspaceDirRole(entry.path, entry.name, entry.kind) != null;
if (!canManage || isStructural) return null;
return (
<button
type="button"
data-testid={`space-file-rename-${entry.name}`}
onClick={() => void renameEntry(entry)}
title={t('files.rename')}
aria-label={t('files.renameAria', { name: entry.name })}
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M10.5 2.5l3 3L6 13l-3.5.5L3 10z" />
</svg>
</button>
);
};
return (
<div data-testid="space-files" className="flex flex-col gap-3">
{/* パンくず(現在地)+ 操作。パスはパンくずのみで表す(テキスト二重表示を廃止)。 */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 pt-1 font-mono text-2xs text-slate-500 break-all">
/files{currentPath ? `/${currentPath}` : ''}
<div className="min-w-0 flex-1 pt-0.5">
<FileBreadcrumb
testid="space-files-breadcrumb"
pathSegments={pathSegments}
onNavigate={setCurrentPath}
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
/>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{viewMode === 'icon' && <FileSortMenu sort={menuSort} onChange={onMenuSort} />}
<FileViewToggle idPrefix="space" mode={viewMode} onChange={setViewMode} />
{canManage && (
<button
type="button"
data-testid="space-files-mkdir-btn"
onClick={() => void createFolder()}
className="inline-flex items-center gap-1 px-2 h-7 rounded text-2xs font-medium border border-hairline bg-canvas text-slate-600 hover:bg-surface transition-colors"
title={t('files.mkdirTitle')}
>
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M2 4.5A1.5 1.5 0 013.5 3h2.6l1.2 1.6h5.2A1.5 1.5 0 0116 6.1V12a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 012 12V4.5zM8 7.5v4M6 9.5h4" />
</svg>
{t('files.newFolder')}
</button>
)}
<FileActions
idPrefix="space"
canUpload={canManage}
onUploadFiles={files => void uploadFiles(files)}
onRefresh={() => void load()}
isRefreshing={isRefreshing}
isUploading={isUploading}
/>
</div>
<FileActions
idPrefix="space"
canUpload={canManage}
onUploadFiles={files => void uploadFiles(files)}
onRefresh={() => void load()}
isRefreshing={isRefreshing}
isUploading={isUploading}
/>
</div>
<FileBreadcrumb testid="space-files-breadcrumb" pathSegments={pathSegments} onNavigate={setCurrentPath} />
{!currentPath && !loadError && selectablePaths.length === 0 && (
<p className="text-2xs text-slate-500 leading-relaxed">
{t('files.inputHint')}
</p>
)}
{canManage && selectablePaths.length > 0 && (
<FileSelectionBar
@@ -1120,8 +1254,10 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
selectedCount={selectedInView.length}
onDeleteSelected={() => void deleteSelected(selectedInView)}
onDownloadSelected={() => void downloadSelected(selectedInView)}
onMoveSelected={() => setMoveDialogSources(selectedInView)}
isDeleting={isDeleting}
isDownloading={isDownloading}
isMoving={isMoving}
/>
)}
@@ -1135,39 +1271,87 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
enabled={canManage}
isUploading={isUploading}
onDropFiles={files => void uploadFiles(files)}
onRejectFolder={() => setUploadMsg({ text: 'フォルダは未対応です。ファイルを選んでください。', kind: 'error' })}
onRejectFolder={() => setUploadMsg({ text: t('files.folderNotSupported'), kind: 'error' })}
>
<FileTileGrid
entries={sorted}
idPrefix="space"
canManage={canManage}
selected={selected}
onToggleSelect={toggleSelect}
onOpenDir={setCurrentPath}
onOpenFile={(path, name) => void handlePreview(path, name)}
onDeleteOne={path => void deleteSelected([path])}
isDeleting={isDeleting}
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
renderTileOverlay={entry => {
// apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
if (!appEntry) return null;
return (
<button
type="button"
data-testid={`space-app-run-${appEntry.appName}`}
onClick={() => setAppToRun(appEntry)}
title="アプリとして実行"
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100"
>
</button>
);
}}
emptyHint={loadError ? null : (canManage
? 'ファイルがありません。ここにドラッグ&ドロップ、または「+ 追加」で追加できます。'
: 'ファイルがありません。')}
/>
{viewMode === 'detail' ? (
<FileDetailList
entries={sortedEntries}
idPrefix="space"
canManage={canManage}
selected={selected}
onToggleSelect={toggleSelect}
onOpenDir={setCurrentPath}
onOpenFile={(path, name) => void handlePreview(path, name)}
onDeleteOne={path => void deleteSelected([path])}
isDeleting={isDeleting}
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
onDownloadDir={path => void downloadSelected([path])}
sort={sort}
onSort={toggleSort}
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
renderRowAction={entry => {
// 詳細表示でも apps/{name}/index.html は「実行」で起動できるようにする
// (アイコン表示の renderTileOverlay と対)。
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
const rename = renameButton(entry);
if (!appEntry && !rename) return null;
return (
<>
{rename}
{appEntry && (
<button
type="button"
data-testid={`space-app-run-${appEntry.appName}`}
onClick={() => setAppToRun(appEntry)}
title={t('files.runAsApp')}
className="inline-flex h-5 items-center rounded bg-[var(--brand-primary)] px-1.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-90 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
{t('files.run')}
</button>
)}
</>
);
}}
emptyHint={loadError ? null : (canManage
? t('files.emptyManage')
: t('files.empty'))}
/>
) : (
<FileTileGrid
entries={sortedEntries}
idPrefix="space"
canManage={canManage}
selected={selected}
onToggleSelect={toggleSelect}
onOpenDir={setCurrentPath}
onOpenFile={(path, name) => void handlePreview(path, name)}
onDeleteOne={path => void deleteSelected([path])}
isDeleting={isDeleting}
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
onDownloadDir={path => void downloadSelected([path])}
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
renderEntryAction={renameButton}
renderTileOverlay={entry => {
// apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
if (!appEntry) return null;
return (
<button
type="button"
data-testid={`space-app-run-${appEntry.appName}`}
onClick={() => setAppToRun(appEntry)}
title={t('files.runAsApp')}
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
>
{t('files.runAsApp')}
</button>
);
}}
emptyHint={loadError ? null : (canManage
? t('files.emptyManage')
: t('files.empty'))}
/>
)}
</FileDropzone>
{preview && (
@@ -1177,18 +1361,32 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
imageSrc={preview.imageSrc}
markdownImageBaseUrl={preview.markdownImageBaseUrl}
trustedHtmlUrl={preview.trustedHtmlUrl}
office={preview.office}
onClose={() => setPreview(null)}
/>
)}
{appToRun && (
<AppRunner
spaceId={spaceId}
gateway={createSpaceGateway(spaceId)}
appName={appToRun.appName}
entryPath={appToRun.entryPath}
onClose={() => setAppToRun(null)}
/>
)}
{moveDialogSources && (
<MoveTargetDialog
spaceId={spaceId}
sourcePaths={moveDialogSources}
isMoving={isMoving}
onClose={() => setMoveDialogSources(null)}
onConfirm={dest => {
setMoveDialogSources(null);
void moveInto(moveDialogSources, dest);
}}
/>
)}
</div>
);
}
@@ -1,41 +1,60 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Dialog from '@radix-ui/react-dialog';
import { useCreateSpace } from '../../hooks/useSpaces';
import { useCreateSpace, useUpdateSpace } from '../../hooks/useSpaces';
import type { Space } from '../../api';
interface CreateSpaceDialogProps {
interface SpaceFormDialogProps {
/** 指定すると編集モード(名前・色・説明を更新)。未指定なら新規作成モード。 */
space?: Space;
onClose: () => void;
onCreated?: (id: string) => void;
onSaved?: (id: string) => void;
}
// DESIGN.md のブランド設定 UI に倣ったプリセット色。
const PRESET_COLORS = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#64748b'];
export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps) {
/**
* `space`
*
*/
export function SpaceFormDialog({ space, onClose, onSaved }: SpaceFormDialogProps) {
const { t } = useTranslation('spaces');
const isEdit = !!space;
const createSpace = useCreateSpace();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [brandColor, setBrandColor] = useState<string>(PRESET_COLORS[0]);
const updateSpace = useUpdateSpace();
const [title, setTitle] = useState(space?.title ?? '');
const [description, setDescription] = useState(space?.description ?? '');
const [brandColor, setBrandColor] = useState<string>(space?.brandColor ?? PRESET_COLORS[0]);
const [error, setError] = useState('');
const submitting = createSpace.isPending;
const submitting = createSpace.isPending || updateSpace.isPending;
const handleSubmit = async () => {
const trimmed = title.trim();
if (!trimmed) {
setError('ワークスペース名を入力してください。');
setError(t('formDialog.error.nameRequired'));
return;
}
setError('');
try {
const space = await createSpace.mutateAsync({
title: trimmed,
description: description.trim() || undefined,
brandColor: brandColor || null,
});
onCreated?.(space.id);
if (isEdit) {
const updated = await updateSpace.mutateAsync({
id: space!.id,
patch: { title: trimmed, description: description.trim(), brandColor: brandColor || null },
});
onSaved?.(updated.id);
} else {
const created = await createSpace.mutateAsync({
title: trimmed,
description: description.trim() || undefined,
brandColor: brandColor || null,
});
onSaved?.(created.id);
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : 'ワークスペースを作成できませんでした。');
setError(e instanceof Error ? e.message : isEdit ? t('formDialog.error.updateFailed') : t('formDialog.error.createFailed'));
}
};
@@ -51,15 +70,17 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
{isEdit ? t('formDialog.title.edit') : t('formDialog.title.create')}
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
{isEdit
? t('formDialog.description.edit')
: t('formDialog.description.create')}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
aria-label="閉じる"
aria-label={t('formDialog.close')}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
@@ -71,44 +92,45 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold text-slate-600"><span className="text-red-500"> *</span></span>
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.name')}<span className="text-red-500"> *</span></span>
<input
autoFocus
data-testid="space-title-input"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="例: ◯◯社 受託PJ"
placeholder={t('formDialog.field.namePlaceholder')}
className="rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold text-slate-600"></span>
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.description')}</span>
<textarea
data-testid="space-description-input"
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="このワークスペースで扱う案件の概要"
placeholder={t('formDialog.field.descriptionPlaceholder')}
className="resize-none rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
</label>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-slate-600"></span>
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.brandColor')}</span>
<div className="flex items-center gap-2">
{PRESET_COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => setBrandColor(c)}
aria-label={`${c}`}
aria-label={t('formDialog.colorSwatch', { color: c })}
className={`h-6 w-6 rounded-full transition-transform ${
brandColor === c ? 'ring-2 ring-offset-2 ring-[var(--brand-primary)]' : ''
}`}
style={{ background: c }}
/>
))}
<label className="ml-1 flex cursor-pointer items-center" title="自由に選択">
<label className="ml-1 flex cursor-pointer items-center" title={t('formDialog.customColor')}>
<input
type="color"
value={brandColor}
@@ -127,17 +149,17 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
type="button"
className="rounded-md border border-hairline px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-surface-2"
>
{t('common:cancel')}
</button>
</Dialog.Close>
<button
type="button"
data-testid="create-space-submit"
data-testid="space-form-submit"
onClick={handleSubmit}
disabled={submitting}
className="rounded-md bg-accent px-4 py-2 text-sm font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
>
{submitting ? '作成中…' : '作成'}
{submitting ? (isEdit ? t('formDialog.saving') : t('formDialog.creating')) : (isEdit ? t('common:save') : t('formDialog.createButton'))}
</button>
</div>
</div>
+50 -55
View File
@@ -16,6 +16,7 @@
* 「認証を有効化するとスペースを共有できます」の案内を出す。
*/
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceMembers,
@@ -35,12 +36,6 @@ import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
const ROLE_LABEL: Record<SpaceMemberRole, string> = {
owner: 'オーナー',
editor: '編集者',
viewer: '閲覧者',
};
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
@@ -58,6 +53,7 @@ function Avatar({ url, name }: { url: string | null; name: string | null }) {
}
export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const auth = useAuthState();
const qc = useQueryClient();
@@ -82,24 +78,24 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
mutationFn: ({ userId, role }: { userId: string; role: SpaceMemberRole }) =>
updateSpaceMemberRole(spaceId, userId, role),
onSuccess: invalidate,
onError: (e) => showToast?.(`ロールの変更に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('members.toast.roleChangeFailed', { msg: errMsg(e) }), 'error'),
});
const removeMut = useMutation({
mutationFn: (userId: string) => removeSpaceMember(spaceId, userId),
onSuccess: invalidate,
onError: (e) => showToast?.(`メンバーの除去に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('members.toast.removeFailed', { msg: errMsg(e) }), 'error'),
});
const addMut = useMutation({
mutationFn: (input: { userId: string; role: SpaceMemberRole }) => addSpaceMember(spaceId, input),
onSuccess: () => { invalidate(); setPicking(false); },
onError: (e) => showToast?.(`メンバーの追加に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('members.toast.addFailed', { msg: errMsg(e) }), 'error'),
});
const handleRemove = (m: SpaceMember) => {
const who = m.name ?? m.email ?? m.userId;
if (window.confirm(`${who} をこのワークスペースから除去しますか?`)) {
if (window.confirm(t('members.removeConfirm', { who }))) {
removeMut.mutate(m.userId);
}
};
@@ -108,15 +104,15 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
<div className="h-full overflow-y-auto" data-testid="space-members-panel">
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1"></h2>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('members.heading')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
{t('members.intro')}
</p>
</div>
{isLoading && <div className="text-[13px] text-slate-400"></div>}
{isLoading && <div className="text-[13px] text-slate-400">{t('common:loading')}</div>}
{isError && (
<div className="text-[13px] text-red-600">: {errMsg(error)}</div>
<div className="text-[13px] text-red-600">{t('members.fetchError', { msg: errMsg(error) })}</div>
)}
{!isLoading && !isError && (
@@ -135,7 +131,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
</span>
{m.isOwner && (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 dark:bg-blue-500/15 text-blue-600 dark:text-blue-300 leading-none">
{t('members.role.owner')}
</span>
)}
</div>
@@ -154,8 +150,8 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
}
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
<option value="editor">{ROLE_LABEL.editor}</option>
<option value="viewer">{ROLE_LABEL.viewer}</option>
<option value="editor">{t('members.role.editor')}</option>
<option value="viewer">{t('members.role.viewer')}</option>
</select>
<button
type="button"
@@ -164,7 +160,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
disabled={removeMut.isPending}
className="text-2xs text-red-600 hover:text-red-800 dark:hover:text-red-300 underline disabled:opacity-50"
>
{t('members.remove')}
</button>
</>
) : (
@@ -172,7 +168,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
data-testid={`space-member-role-${m.userId}`}
className="text-2xs text-slate-500"
>
{ROLE_LABEL[m.role]}
{t(`members.role.${m.role}`)}
</span>
)}
</div>
@@ -204,7 +200,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
onClick={() => setPicking(true)}
className="px-3 py-1.5 rounded-md text-xs font-semibold text-accent border border-accent/30 hover:bg-accent-soft transition-colors"
>
{t('members.inviteButton')}
</button>
)}
</div>
@@ -214,15 +210,10 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
);
}
const INVITE_ROLE_LABEL: Record<SpaceInviteRole, string> = {
editor: '編集者',
viewer: '閲覧者',
};
const EXPIRY_OPTIONS: Array<{ label: string; days: number | null }> = [
{ label: '無期限', days: null },
{ label: '7日', days: 7 },
{ label: '30日', days: 30 },
const EXPIRY_OPTIONS: Array<{ labelKey: string; days: number | null }> = [
{ labelKey: 'members.invite.expiry.never', days: null },
{ labelKey: 'members.invite.expiry.days7', days: 7 },
{ labelKey: 'members.invite.expiry.days30', days: 30 },
];
/**
@@ -231,6 +222,7 @@ const EXPIRY_OPTIONS: Array<{ label: string; days: number | null }> = [
* 組織(pickable の絞り込み)に依存しない招待経路になる。
*/
function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const qc = useQueryClient();
const [role, setRole] = useState<SpaceInviteRole>('viewer');
const [expiryIdx, setExpiryIdx] = useState(0);
@@ -247,13 +239,13 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
const createMut = useMutation({
mutationFn: () => createSpaceInvite(spaceId, { role, expiresInDays: EXPIRY_OPTIONS[expiryIdx].days }),
onSuccess: invalidate,
onError: (e) => showToast?.(`招待リンクの作成に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('members.toast.inviteCreateFailed', { msg: errMsg(e) }), 'error'),
});
const revokeMut = useMutation({
mutationFn: () => revokeSpaceInvite(spaceId),
onSuccess: invalidate,
onError: (e) => showToast?.(`招待リンクの無効化に失敗しました: ${errMsg(e)}`, 'error'),
onError: (e) => showToast?.(t('members.toast.inviteRevokeFailed', { msg: errMsg(e) }), 'error'),
});
const absoluteUrl = invite ? `${window.location.origin}${invite.url}` : '';
@@ -265,21 +257,21 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
showToast?.('コピーに失敗しました。手動で選択してください。', 'error');
showToast?.(t('members.toast.copyFailed'), 'error');
}
};
return (
<div data-testid="space-invite-section" className="rounded-md border border-hairline bg-surface/40 p-4 space-y-3 max-w-md">
<div>
<h3 className="text-[13px] font-semibold text-slate-900"></h3>
<h3 className="text-[13px] font-semibold text-slate-900">{t('members.invite.title')}</h3>
<p className="text-2xs text-slate-500 leading-relaxed mt-0.5">
{t('members.invite.description')}
</p>
</div>
{isLoading ? (
<div className="text-[13px] text-slate-400"></div>
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
) : active ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
@@ -296,13 +288,15 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
onClick={handleCopy}
className="h-8 shrink-0 rounded-md bg-accent px-3 text-xs font-semibold text-accent-fg hover:bg-accent-deep"
>
{copied ? 'コピー済' : 'コピー'}
{copied ? t('members.invite.copied') : t('members.invite.copy')}
</button>
</div>
<div className="flex items-center justify-between text-2xs text-slate-500">
<span>
: {INVITE_ROLE_LABEL[invite.role]}
{invite.expiresAt ? ` ・ 期限: ${new Date(invite.expiresAt.replace(' ', 'T') + 'Z').toLocaleDateString()}` : ' ・ 無期限'}
{t('members.invite.roleLabel', { role: t(`members.role.${invite.role}`) })}
{invite.expiresAt
? t('members.invite.expiresAt', { date: new Date(invite.expiresAt.replace(' ', 'T') + 'Z').toLocaleDateString() })
: t('members.invite.noExpiry')}
</span>
<div className="flex items-center gap-2">
<button
@@ -312,7 +306,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
disabled={createMut.isPending}
className="text-slate-600 underline hover:text-slate-900 disabled:opacity-50"
>
{t('members.invite.regenerate')}
</button>
<button
type="button"
@@ -321,7 +315,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
disabled={revokeMut.isPending}
className="text-red-600 underline hover:text-red-800 disabled:opacity-50"
>
{t('members.invite.revoke')}
</button>
</div>
</div>
@@ -335,8 +329,8 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
onChange={(e) => setRole(e.target.value as SpaceInviteRole)}
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
<option value="viewer">{INVITE_ROLE_LABEL.viewer}</option>
<option value="editor">{INVITE_ROLE_LABEL.editor}</option>
<option value="viewer">{t('members.role.viewer')}</option>
<option value="editor">{t('members.role.editor')}</option>
</select>
<select
data-testid="space-invite-expiry"
@@ -345,7 +339,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
{EXPIRY_OPTIONS.map((o, i) => (
<option key={i} value={i}>{o.label}</option>
<option key={i} value={i}>{t(o.labelKey)}</option>
))}
</select>
<button
@@ -355,7 +349,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
disabled={createMut.isPending}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg hover:bg-accent-deep disabled:opacity-50"
>
{createMut.isPending ? '作成中…' : '招待リンクを作成'}
{createMut.isPending ? t('members.invite.creating') : t('members.invite.create')}
</button>
</div>
</div>
@@ -377,6 +371,7 @@ function InvitePicker({
onCancel: () => void;
onAdd: (userId: string, role: SpaceMemberRole) => void;
}) {
const { t } = useTranslation('spaces');
const { data: users, isLoading } = useQuery({
queryKey: ['pickable-users', spaceId],
queryFn: fetchPickableUsers,
@@ -409,14 +404,14 @@ function InvitePicker({
data-testid="space-member-picker"
className="rounded-md border border-hairline bg-surface/50 p-4 text-[13px] text-slate-500"
>
{t('members.picker.authRequired')}
<div className="mt-3">
<button
type="button"
onClick={onCancel}
className="text-xs text-slate-600 hover:text-slate-800 underline"
>
{t('members.picker.close')}
</button>
</div>
</div>
@@ -432,23 +427,23 @@ function InvitePicker({
className="rounded-md border border-hairline bg-surface/50 p-4 space-y-3 max-w-md"
>
<p data-testid="space-member-picker-org-note" className="text-2xs text-slate-500 leading-relaxed">
{t('members.picker.orgNote')}
</p>
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="名前・メールで検索"
placeholder={t('members.picker.searchPlaceholder')}
className="h-8 w-full rounded-md border border-hairline px-2 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
{isLoading ? (
<div className="text-[13px] text-slate-400"></div>
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
) : candidates.length === 0 ? (
<div className="text-[13px] text-slate-400">
{allAlreadyAdded
? '同じ組織のメンバーは全員このワークスペースに追加済みです。'
: '追加できるユーザーがいません。同じ組織のメンバーだけが候補に表示されます。'}
? t('members.picker.allAdded')
: t('members.picker.noCandidates')}
</div>
) : (
<div className="max-h-56 overflow-y-auto">
@@ -480,8 +475,8 @@ function InvitePicker({
onChange={(e) => setRole(e.target.value as SpaceMemberRole)}
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
<option value="editor">{ROLE_LABEL.editor}</option>
<option value="viewer">{ROLE_LABEL.viewer}</option>
<option value="editor">{t('members.role.editor')}</option>
<option value="viewer">{t('members.role.viewer')}</option>
</select>
<button
type="button"
@@ -489,14 +484,14 @@ function InvitePicker({
onClick={() => selectedId && onAdd(selectedId, role)}
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
>
{isPending ? '追加中…' : '追加'}
{isPending ? t('members.picker.adding') : t('members.picker.add')}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-1.5 rounded-md text-xs text-slate-700 border border-hairline hover:bg-surface transition-colors"
>
{t('common:cancel')}
</button>
</div>
</div>
+168
View File
@@ -0,0 +1,168 @@
// @vitest-environment jsdom
/**
* Component test for SpaceRail — the workspace switcher rail. Mocks the data
* hooks (useSpaces, useLocalTaskList) and useAuthState so the real grouping,
* "自分" badge, running-count badge, selection, empty/loading/error states and
* the create dialog toggle are exercised. SpaceFormDialog is stubbed so opening
* it doesn't pull in the full create-dialog tree.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import type { Space } from '../../api';
import i18n from '../../i18n';
// --- Mocks (declared before importing the component under test) -------------
const useSpacesMock = vi.fn();
const useTaskListMock = vi.fn();
const useAuthStateMock = vi.fn();
vi.mock('../../hooks/useSpaces', () => ({ useSpaces: () => useSpacesMock() }));
vi.mock('../../hooks/useTaskList', () => ({ useLocalTaskList: () => useTaskListMock() }));
vi.mock('../../App', () => ({ useAuthState: () => useAuthStateMock() }));
vi.mock('./SpaceFormDialog', () => ({
SpaceFormDialog: ({ onSaved }: { onSaved: (id: string) => void }) => (
<div data-testid="space-form-dialog">
<button onClick={() => onSaved('new-space')}>save</button>
</div>
),
}));
import { SpaceRail } from './SpaceRail';
function space(p: Partial<Space> & Pick<Space, 'id' | 'kind' | 'title'>): Space {
return {
description: '',
ownerId: null,
visibility: 'private',
visibilityScopeOrgId: null,
status: 'open',
brandColor: null,
workspaceDir: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...p,
} as Space;
}
beforeEach(() => {
// Rail labels (loading/error/empty/running-count/"自分" badge) now route through
// i18next. Pin the language so text assertions are deterministic.
void i18n.changeLanguage('ja');
useSpacesMock.mockReset();
useTaskListMock.mockReset();
useAuthStateMock.mockReset();
useTaskListMock.mockReturnValue({ data: [] });
useAuthStateMock.mockReturnValue({ mode: 'disabled' });
});
describe('SpaceRail', () => {
it('shows the loading state', () => {
useSpacesMock.mockReturnValue({ data: undefined, isLoading: true, isError: false });
render(<SpaceRail onSelect={() => {}} />);
expect(screen.getByText('読み込み中…')).toBeInTheDocument();
});
it('shows the error state', () => {
useSpacesMock.mockReturnValue({ data: undefined, isLoading: false, isError: true });
render(<SpaceRail onSelect={() => {}} />);
expect(screen.getByText('ワークスペースを取得できませんでした')).toBeInTheDocument();
});
it('shows the empty state when there are no spaces', () => {
useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false });
render(<SpaceRail onSelect={() => {}} />);
expect(
screen.getByText('ワークスペースがありません。「+ 新規」から作成してください。'),
).toBeInTheDocument();
});
it('groups personal and case spaces under their headers', () => {
useSpacesMock.mockReturnValue({
data: [
space({ id: 'p1', kind: 'personal', title: 'My Workspace' }),
space({ id: 'c1', kind: 'case', title: 'Project A' }),
],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
const groups = screen.getAllByTestId('space-group');
expect(groups.map((g) => g.getAttribute('data-group'))).toEqual([
'統合スペース',
'個別スペース',
]);
expect(screen.getByText('My Workspace')).toBeInTheDocument();
expect(screen.getByText('Project A')).toBeInTheDocument();
});
it('calls onSelect with the space id when a row is clicked', () => {
const onSelect = vi.fn();
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={onSelect} />);
fireEvent.click(screen.getByText('Project A'));
expect(onSelect).toHaveBeenCalledWith('c1');
});
it('renders a running-count badge when a space has running tasks', () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
useTaskListMock.mockReturnValue({
data: [
{ spaceId: 'c1', latestJob: { status: 'running' } },
{ spaceId: 'c1', latestJob: { status: 'succeeded' } },
],
});
render(<SpaceRail onSelect={() => {}} />);
const badge = screen.getByTestId('space-running-count');
expect(badge).toHaveTextContent('1 実行中');
});
it('shows the "自分" badge only when other users\' spaces are present (admin view)', () => {
useAuthStateMock.mockReturnValue({ mode: 'authenticated', user: { id: 'me' } });
useSpacesMock.mockReturnValue({
data: [
space({ id: 'c1', kind: 'case', title: 'Mine', ownerId: 'me' }),
space({ id: 'c2', kind: 'case', title: 'Theirs', ownerId: 'other' }),
],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
const badges = screen.getAllByTestId('space-mine-badge');
expect(badges).toHaveLength(1);
// Badge lives inside the owner's row.
const mineRow = screen.getByText('Mine').closest('[data-testid="space-row"]');
expect(mineRow).toHaveAttribute('data-space-mine', '1');
});
it('hides the "自分" badge when all spaces belong to the viewer', () => {
useAuthStateMock.mockReturnValue({ mode: 'authenticated', user: { id: 'me' } });
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Mine', ownerId: 'me' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
expect(screen.queryByTestId('space-mine-badge')).toBeNull();
});
it('opens the create dialog and selects the new space on save', () => {
const onSelect = vi.fn();
useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false });
render(<SpaceRail onSelect={onSelect} />);
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
fireEvent.click(screen.getByTestId('create-space-btn'));
expect(screen.getByTestId('space-form-dialog')).toBeInTheDocument();
fireEvent.click(screen.getByText('save'));
expect(onSelect).toHaveBeenCalledWith('new-space');
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
});
});
+104 -27
View File
@@ -1,69 +1,116 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState } from '../../App';
import { useSpaces } from '../../hooks/useSpaces';
import { useLocalTaskList } from '../../hooks/useTaskList';
import { sortSpacesForRail } from '../../lib/spaceSort';
import { countRunningTasksForSpace } from '../../lib/spaceTasks';
import { statusTone } from '../../lib/utils';
import type { Space } from '../../api';
import { CreateSpaceDialog } from './CreateSpaceDialog';
import { SpaceFormDialog } from './SpaceFormDialog';
interface SpaceRailProps {
selectedId?: string;
onSelect: (id: string) => void;
}
const VIS_LABEL: Record<Space['visibility'], string> = {
private: 'private',
// 可視性ラベルは private を出さない(既定値でノイズになるため)。org/public のみ
// 意味があるので表示する。
const VIS_LABEL: Partial<Record<Space['visibility'], string>> = {
org: 'org',
public: 'public',
};
// グループの色帯。統合スペース(個人=作業が集まる中心)はブランド色、個別スペース
// (案件=プロジェクトごとに分かれる)は中立色で、左端の帯で一目で区別する。
const GROUP_BAND_INTEGRATED = 'var(--brand-primary)';
const GROUP_BAND_INDIVIDUAL = '#94a3b8'; // slate-400
interface SpaceGroupDef {
/** Stable key used for data-group (test selector); decoupled from the display label. */
key: string;
label: string;
band: string;
spaces: Space[];
}
export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
const { t } = useTranslation('spaces');
const { data: spaces, isLoading, isError } = useSpaces();
// 実行中件数の算出元。リスト API はスペースで絞らないので全件を保持しており、
// FAST ポーリングで自動更新される。スペースごとにクライアント側で数える。
const { data: tasks } = useLocalTaskList();
const [showCreate, setShowCreate] = useState(false);
const auth = useAuthState();
const myUserId = auth.mode === 'authenticated' ? auth.user.id : null;
const sorted = useMemo(() => sortSpacesForRail(spaces ?? []), [spaces]);
const personal = sorted.filter(s => s.kind === 'personal');
const cases = sorted.filter(s => s.kind !== 'personal');
// 他ユーザー所有のスペースが一覧に混在するとき(=admin が全ユーザーのスペースを
// 見ている場合)だけ「自分」バッジを出す。単一ユーザーの一覧では全部自分なので
// ノイズにしかならず、出さない(issue #003)。
const hasOthersSpaces = useMemo(
() => myUserId != null && sorted.some(s => s.ownerId != null && s.ownerId !== myUserId),
[sorted, myUserId],
);
const groups = useMemo<SpaceGroupDef[]>(() => {
const personal = sorted.filter(s => s.kind === 'personal');
const cases = sorted.filter(s => s.kind !== 'personal');
const out: SpaceGroupDef[] = [];
if (personal.length > 0) out.push({ key: '統合スペース', label: t('rail.group.integrated'), band: GROUP_BAND_INTEGRATED, spaces: personal });
if (cases.length > 0) out.push({ key: '個別スペース', label: t('rail.group.individual'), band: GROUP_BAND_INDIVIDUAL, spaces: cases });
return out;
}, [sorted, t]);
return (
<div className="flex h-full flex-col overflow-hidden" data-testid="space-rail">
<div className="flex items-center justify-between border-b border-hairline px-3 py-2.5">
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500"></span>
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">{t('rail.heading')}</span>
<button
type="button"
data-testid="create-space-btn"
onClick={() => setShowCreate(true)}
className="rounded-md border border-hairline px-2 py-1 text-xs font-semibold text-slate-700 transition-colors hover:bg-surface-2"
>
{t('rail.new')}
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{isLoading && <p className="px-1 py-2 text-xs text-slate-500"></p>}
{isError && <p className="px-1 py-2 text-xs text-red-600"></p>}
{isLoading && <p className="px-1 py-2 text-xs text-slate-500">{t('common:loading')}</p>}
{isError && <p className="px-1 py-2 text-xs text-red-600">{t('rail.fetchError')}</p>}
{personal.length > 0 && (
<div className="mb-1 px-1 pt-1 text-[10px] font-bold uppercase tracking-wider text-slate-400"></div>
)}
{personal.map(s => (
<SpaceRow key={s.id} space={s} active={s.id === selectedId} onSelect={onSelect} />
))}
{cases.length > 0 && (
<div className="mb-1 mt-2 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400"></div>
)}
{cases.map(s => (
<SpaceRow key={s.id} space={s} active={s.id === selectedId} onSelect={onSelect} />
{groups.map((g, i) => (
<section
key={g.key}
data-testid="space-group"
data-group={g.key}
className={`border-l-2 pl-2 ${i > 0 ? 'mt-2 border-t border-hairline pt-2' : ''}`}
style={{ borderLeftColor: g.band }}
>
<div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">{g.label}</div>
{g.spaces.map(s => (
<SpaceRow
key={s.id}
space={s}
active={s.id === selectedId}
onSelect={onSelect}
runningCount={countRunningTasksForSpace(tasks ?? [], s)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
/>
))}
</section>
))}
{!isLoading && !isError && sorted.length === 0 && (
<p className="px-1 py-2 text-xs text-slate-500"> </p>
<p className="px-1 py-2 text-xs text-slate-500">{t('rail.empty')}</p>
)}
</div>
{showCreate && (
<CreateSpaceDialog
<SpaceFormDialog
onClose={() => setShowCreate(false)}
onCreated={(id) => {
onSaved={(id) => {
setShowCreate(false);
onSelect(id);
}}
@@ -77,18 +124,26 @@ function SpaceRow({
space,
active,
onSelect,
runningCount,
mine,
}: {
space: Space;
active: boolean;
onSelect: (id: string) => void;
runningCount: number;
/** 他ユーザーのスペースが混在する一覧で、これが閲覧者自身の所有なら true。 */
mine?: boolean;
}) {
const { t } = useTranslation('spaces');
const dot = space.brandColor ?? 'var(--brand-primary)';
const runningStyle = statusTone('running');
return (
<button
type="button"
data-testid="space-row"
data-space-kind={space.kind}
data-space-id={space.id}
data-space-mine={mine ? '1' : undefined}
onClick={() => onSelect(space.id)}
className={`mb-0.5 flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left transition-colors ${
active
@@ -102,9 +157,31 @@ function SpaceRow({
aria-hidden
/>
<span className="flex-1 truncate text-[12.5px] font-semibold text-slate-800">{space.title}</span>
<span className="font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
{VIS_LABEL[space.visibility]}
</span>
{mine && (
<span
data-testid="space-mine-badge"
className="shrink-0 rounded-full bg-[var(--brand-primary-soft)] px-1.5 py-0.5 text-[9px] font-bold tracking-wide text-[var(--brand-primary)]"
title={t('rail.mineTitle')}
>
{t('rail.mine')}
</span>
)}
{VIS_LABEL[space.visibility] && (
<span className="shrink-0 font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
{VIS_LABEL[space.visibility]}
</span>
)}
{runningCount > 0 && (
<span
data-testid="space-running-count"
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold tabular-nums"
style={{ background: runningStyle.bg, color: runningStyle.fg }}
title={t('rail.runningTitle', { count: runningCount })}
aria-label={t('rail.runningAria', { count: runningCount })}
>
{t('rail.running', { count: runningCount })}
</span>
)}
</button>
);
}
+25 -19
View File
@@ -12,6 +12,7 @@
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { AgentsMdPanel } from '../userfolder/AgentsMdPanel';
import { MemoryPanel } from '../userfolder/MemoryPanel';
@@ -20,6 +21,7 @@ import { McpPanel } from '../userfolder/McpPanel';
import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
import { SpaceMembersPanel } from './SpaceMembersPanel';
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
import { SpaceToolSettings } from './SpaceToolSettings';
import { PieceEditor } from '../settings/PieceEditor';
import { usePieceList } from '../../hooks/usePieces';
import { splitPieces } from '../../lib/splitPieces';
@@ -28,27 +30,29 @@ import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members';
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools';
const SECTIONS: { id: SettingsSection; label: string; testid: string }[] = [
{ id: 'agents', label: 'AGENTS.md', testid: 'space-settings-nav-agents' },
{ id: 'memory', label: 'メモリ', testid: 'space-settings-nav-memory' },
{ id: 'pieces', label: 'Pieces', testid: 'space-settings-nav-pieces' },
{ id: 'skills', label: 'スキル', testid: 'space-settings-nav-skills' },
{ id: 'mcp', label: 'MCP', testid: 'space-settings-nav-mcp' },
{ id: 'ssh', label: 'SSH', testid: 'space-settings-nav-ssh' },
{ id: 'browser', label: 'ブラウザ', testid: 'space-settings-nav-browser' },
{ id: 'members', label: 'メンバー', testid: 'space-settings-nav-members' },
const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' },
{ id: 'memory', labelKey: 'settings.nav.memory', testid: 'space-settings-nav-memory' },
{ id: 'pieces', labelKey: 'settings.nav.pieces', testid: 'space-settings-nav-pieces' },
{ id: 'skills', labelKey: 'settings.nav.skills', testid: 'space-settings-nav-skills' },
{ id: 'mcp', labelKey: 'settings.nav.mcp', testid: 'space-settings-nav-mcp' },
{ id: 'ssh', labelKey: 'settings.nav.ssh', testid: 'space-settings-nav-ssh' },
{ id: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' },
{ id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' },
{ id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' },
];
export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const [section, setSection] = useState<SettingsSection>('agents');
return (
<div className="flex h-full min-h-0 flex-col md:flex-row md:gap-3">
{/* Sub-nav: モバイルは横スクロールのセグメント、md+ は左の縦リスト。 */}
<nav
aria-label="ワークスペース設定"
aria-label={t('settings.navLabel')}
className="flex shrink-0 gap-1 overflow-x-auto border-b border-hairline pb-2 md:w-44 md:flex-col md:overflow-x-visible md:border-b-0 md:border-r md:pb-0 md:pr-3"
>
{SECTIONS.map(s => {
@@ -65,7 +69,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
: 'text-slate-600 hover:bg-surface hover:text-slate-900'
}`}
>
{s.label}
{t(s.labelKey)}
</button>
);
})}
@@ -80,6 +84,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
{section === 'mcp' && <McpPanel spaceId={spaceId} showToast={showToast} />}
{section === 'ssh' && <SshConnectionsPanel spaceId={spaceId} showToast={showToast} />}
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
</div>
</div>
@@ -92,6 +97,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
* `PieceEditor` の再利用で構成する。選択はローカル state。
*/
function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const auth = useAuthState();
const isAdmin = auth.mode === 'authenticated' && auth.user.role === 'admin';
const qc = useQueryClient();
@@ -129,7 +135,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
setNewName('');
setSelected({ name, source });
} catch (e) {
const msg = `Piece の作成に失敗しました: ${e instanceof Error ? e.message : String(e)}`;
const msg = t('settings.pieces.createFailed', { msg: e instanceof Error ? e.message : String(e) });
if (showToast) showToast(msg, 'error');
else console.error(msg);
} finally {
@@ -158,16 +164,16 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
<div className="flex h-full min-h-0">
{/* 左: 一覧 */}
<div className="w-48 shrink-0 overflow-y-auto border-r border-hairline p-2">
<div className="mb-1 px-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">Default</div>
{defaults.length === 0 && <div className="px-2 text-xs text-slate-400">()</div>}
<div className="mb-1 px-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">{t('settings.pieces.default')}</div>
{defaults.length === 0 && <div className="px-2 text-xs text-slate-400">{t('settings.pieces.none')}</div>}
{defaults.map(p => renderRow(p, true))}
<div className="mb-1 mt-3 flex items-center justify-between px-2">
<span className="text-2xs font-semibold uppercase tracking-wide text-slate-500">Custom</span>
<span className="text-2xs font-semibold uppercase tracking-wide text-slate-500">{t('settings.pieces.custom')}</span>
<button
type="button"
onClick={() => setIsCreating(true)}
title="新しい Piece"
title={t('settings.pieces.new')}
className="flex h-5 w-5 items-center justify-center rounded text-slate-500 hover:bg-surface-2 hover:text-slate-900 text-sm leading-none transition-colors"
>
+
@@ -189,7 +195,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
/>
</div>
)}
{customs.length === 0 && !isCreating && <div className="px-2 text-xs text-slate-400">()</div>}
{customs.length === 0 && !isCreating && <div className="px-2 text-xs text-slate-400">{t('settings.pieces.none')}</div>}
{customs.map(p => renderRow(p, false))}
</div>
@@ -204,7 +210,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
onDeleted={() => setSelected(null)}
/>
) : (
<div className="text-sm text-slate-400"> Piece </div>
<div className="text-sm text-slate-400">{t('settings.pieces.selectHint')}</div>
)}
</div>
</div>
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
/**
* Component tests for SpaceToolSettings (tool-policy / sensitive-tools UI).
*
* This is the PR #653 bug class: a sensitive *tool* (Bash) is delivered in a
* separate `sensitiveTools` array, NOT in `categories`. The save patch
* (buildPolicyPatch) must fold Bash's toggle into `enabledSensitive`, alongside
* sensitive *categories* (ssh / browser). These tests render the real component
* and assert that toggling Bash persists through to the PUT payload — the exact
* regression that the earlier "categories-only" patch builder dropped.
*
* api + App.useAuthState are mocked so no real network and canManage=true.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test/render-helpers';
import i18n from '../../i18n';
const { fetchSpaceToolPolicyMock, updateSpaceToolPolicyMock, fetchSpaceMembersMock } = vi.hoisted(() => ({
fetchSpaceToolPolicyMock: vi.fn(),
updateSpaceToolPolicyMock: vi.fn(),
fetchSpaceMembersMock: vi.fn(),
}));
vi.mock('../../api', () => ({
fetchSpaceToolPolicy: fetchSpaceToolPolicyMock,
updateSpaceToolPolicy: updateSpaceToolPolicyMock,
fetchSpaceMembers: fetchSpaceMembersMock,
}));
// auth.mode === 'disabled' => canManage = true (no owner check needed).
vi.mock('../../App', () => ({
useAuthState: () => ({ mode: 'disabled' as const }),
}));
import { SpaceToolSettings } from './SpaceToolSettings';
const POLICY = {
policy: { disabledSafe: [], enabledSensitive: [] },
categories: [
{ name: 'web', sensitive: false, enabled: true },
{ name: 'office', sensitive: false, enabled: true },
{ name: 'ssh', sensitive: true, enabled: false },
{ name: 'browser', sensitive: true, enabled: false },
],
// Bash arrives as a SEPARATE sensitive tool, not a category — the PR #653 trap.
sensitiveTools: [{ name: 'Bash', enabled: false }],
};
beforeEach(() => {
vi.clearAllMocks();
// Labels like the Save button now route through i18next. Pin the language so
// assertions on rendered text are deterministic regardless of detector state.
void i18n.changeLanguage('ja');
fetchSpaceMembersMock.mockResolvedValue([]);
fetchSpaceToolPolicyMock.mockResolvedValue(structuredClone(POLICY));
updateSpaceToolPolicyMock.mockResolvedValue(undefined);
});
async function renderLoaded() {
renderWithProviders(<SpaceToolSettings spaceId="space-1" />);
// Wait for the policy query to resolve and the Bash row to render.
await waitFor(() => expect(screen.getByText('Bash')).toBeInTheDocument());
}
/** Find the role=switch toggle for a row identified by its label text. */
function switchFor(label: string): HTMLElement {
// Walk up from the label until we reach an ancestor that also contains a switch.
let el: HTMLElement | null = screen.getByText(label);
while (el && el.parentElement) {
el = el.parentElement;
const sw = within(el).queryByRole('switch');
if (sw) return sw;
}
throw new Error(`No switch found for row "${label}"`);
}
describe('SpaceToolSettings', () => {
it('renders safe categories, sensitive categories, and the separate Bash tool', async () => {
await renderLoaded();
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText('ssh')).toBeInTheDocument();
expect(screen.getByText('browser')).toBeInTheDocument();
// Bash is the separately-delivered sensitive tool.
expect(screen.getByText('Bash')).toBeInTheDocument();
});
it('Save is disabled until a toggle changes', async () => {
await renderLoaded();
const save = screen.getByRole('button', { name: '保存' });
expect(save).toBeDisabled();
});
it('persists the Bash toggle into enabledSensitive (PR #653 regression)', async () => {
await renderLoaded();
// Each ToggleRow is a role="switch". Find Bash's switch by walking from its label.
await userEvent.click(switchFor('Bash'));
const save = screen.getByRole('button', { name: '保存' });
expect(save).not.toBeDisabled();
await userEvent.click(save);
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
const [spaceId, patch] = updateSpaceToolPolicyMock.mock.calls[0];
expect(spaceId).toBe('space-1');
// The critical assertion: Bash must land in enabledSensitive, NOT be dropped.
expect(patch.enabledSensitive).toContain('Bash');
// No safe category was disabled.
expect(patch.disabledSafe).toEqual([]);
});
it('persists a sensitive CATEGORY toggle (ssh) alongside the tool system', async () => {
await renderLoaded();
await userEvent.click(switchFor('ssh'));
await userEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
const [, patch] = updateSpaceToolPolicyMock.mock.calls[0];
expect(patch.enabledSensitive).toContain('ssh');
expect(patch.enabledSensitive).not.toContain('Bash'); // untouched stays off
});
it('disabling a safe category lands in disabledSafe', async () => {
await renderLoaded();
await userEvent.click(switchFor('web')); // turn OFF (was on)
await userEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
const [, patch] = updateSpaceToolPolicyMock.mock.calls[0];
expect(patch.disabledSafe).toContain('web');
});
});
@@ -0,0 +1,289 @@
/**
* SpaceToolSettings.tsx — ワークスペースごとのツールポリシー設定 UI
*
* - 安全カテゴリ(sensitive=false: デフォルト ON のトグル群
* - センシティブカテゴリ(sensitive=true+ Bash: デフォルト OFF のトグル群。
* 各項目に 1 行のリスク説明を表示。
* - カテゴリ一覧は API から動的取得(ハードコードなし)。
* - オーナーのみ編集可(canManage 判定は SpaceMembersPanel と同じシグナル)。
* - 保存は PUT /api/local/spaces/:id/tool-policyreact-query mutation)。
*/
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceToolPolicy,
fetchSpaceMembers,
updateSpaceToolPolicy,
type ToolCategory,
} from '../../api';
import { useAuthState } from '../../App';
import { splitCategories, buildPolicyPatch, countEnabledCategories } from '../../lib/toolPolicy';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
/** センシティブカテゴリ・ツールに表示する 1 行のリスク説明の翻訳キー。 */
const SENSITIVE_NOTE_KEYS: Record<string, string> = {
ssh: 'tools.sensitiveNote.ssh',
browser: 'tools.sensitiveNote.browser',
Bash: 'tools.sensitiveNote.Bash',
};
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
interface ToggleRowProps {
name: string;
enabled: boolean;
note?: string;
disabled: boolean;
disabledReason?: string;
onChange: (enabled: boolean) => void;
}
function ToggleRow({ name, enabled, note, disabled, disabledReason, onChange }: ToggleRowProps) {
return (
<div className="flex items-start gap-3 py-2.5 border-b border-hairline last:border-b-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-slate-900">{name}</span>
{disabled && disabledReason && (
<span className="text-2xs text-slate-400">({disabledReason})</span>
)}
</div>
{note && (
<p className="text-2xs text-slate-500 leading-relaxed mt-0.5">{note}</p>
)}
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-ring focus:ring-offset-1 disabled:opacity-40 disabled:cursor-not-allowed ${
enabled ? 'bg-accent' : 'bg-slate-300 dark:bg-slate-600'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${
enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
</div>
);
}
export function SpaceToolSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const auth = useAuthState();
const qc = useQueryClient();
const noteFor = (name: string): string | undefined => {
const key = SENSITIVE_NOTE_KEYS[name];
return key ? t(key) : undefined;
};
// メンバー一覧から canManage を判定(SpaceMembersPanel と同じロジック)
const { data: members } = useQuery({
queryKey: ['space-members', spaceId],
queryFn: () => fetchSpaceMembers(spaceId),
staleTime: 30_000,
});
const ownerRow = (members ?? []).find(m => m.isOwner);
const canManage =
auth.mode === 'disabled' ||
(auth.mode === 'authenticated' &&
(auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id)));
// ツールポリシー取得
const { data, isLoading, isError, error } = useQuery({
queryKey: ['space-tool-policy', spaceId],
queryFn: () => fetchSpaceToolPolicy(spaceId),
staleTime: 30_000,
});
// UI ローカルのトグル状態(未保存の変更を保持)
const [toggledSafe, setToggledSafe] = useState<Record<string, boolean>>({});
const [toggledSens, setToggledSens] = useState<Record<string, boolean>>({});
const [savedState, setSavedState] = useState<'idle' | 'saved' | 'error'>('idle');
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ['space-tool-policy', spaceId] });
};
const saveMut = useMutation({
mutationFn: (patch: { disabledSafe: string[]; enabledSensitive: string[] }) =>
updateSpaceToolPolicy(spaceId, patch),
onSuccess: () => {
setSavedState('saved');
setToggledSafe({});
setToggledSens({});
invalidate();
setTimeout(() => setSavedState('idle'), 2000);
},
onError: (e) => {
setSavedState('error');
showToast?.(t('tools.toast.saveFailed', { msg: errMsg(e) }), 'error');
setTimeout(() => setSavedState('idle'), 3000);
},
});
const handleSave = () => {
if (!data) return;
const patch = buildPolicyPatch(data.categories, toggledSafe, toggledSens, data.sensitiveTools ?? []);
saveMut.mutate(patch);
};
const hasPendingChanges = Object.keys(toggledSafe).length > 0 || Object.keys(toggledSens).length > 0;
if (isLoading) {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
</div>
</div>
);
}
if (isError || !data) {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-red-600">
{t('tools.fetchError', { msg: errMsg(error) })}
</div>
</div>
</div>
);
}
const { safe, sensitive } = splitCategories(data.categories);
const enabledCount = countEnabledCategories(data.categories, toggledSafe, toggledSens);
const readonlyReason = t('tools.readonlyReason');
// センシティブグループ: カテゴリ + BashsensitiveTools から取得)
// sensitiveTools は Bash など個別ツールで sensitive=true なもの
const sensitiveBash = data.sensitiveTools ?? [];
const resolveEnabled = (cat: ToolCategory, map: Record<string, boolean>) => {
return Object.prototype.hasOwnProperty.call(map, cat.name) ? map[cat.name] : cat.enabled;
};
const resolveSensToolEnabled = (toolName: string, apiEnabled: boolean) => {
return Object.prototype.hasOwnProperty.call(toggledSens, toolName)
? toggledSens[toolName]
: apiEnabled;
};
return (
<div className="h-full overflow-y-auto" data-testid="space-tool-settings">
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
{/* ヘッダー */}
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('tools.heading')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
{t('tools.intro')}
</p>
<p className="text-[13px] text-slate-500 mt-1">
<Trans
t={t}
i18nKey="tools.enabledCount"
values={{ count: enabledCount }}
components={{ strong: <span className="font-semibold text-slate-700" /> }}
/>
</p>
</div>
{/* 安全カテゴリ(デフォルト ON) */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
{t('tools.standardCategories')}
</h3>
{safe.length === 0 ? (
<p className="text-[13px] text-slate-400">{t('tools.noStandardCategories')}</p>
) : (
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden px-3">
{safe.map(cat => (
<ToggleRow
key={cat.name}
name={cat.name}
enabled={resolveEnabled(cat, toggledSafe)}
disabled={!canManage || saveMut.isPending}
disabledReason={!canManage ? readonlyReason : undefined}
onChange={v => setToggledSafe(prev => ({ ...prev, [cat.name]: v }))}
/>
))}
</div>
)}
</section>
{/* センシティブカテゴリ(デフォルト OFF) */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-1">
{t('tools.sensitiveTools')}
</h3>
<p className="text-2xs text-slate-500 leading-relaxed mb-2">
{t('tools.sensitiveWarning')}
</p>
{sensitive.length === 0 && sensitiveBash.length === 0 ? (
<p className="text-[13px] text-slate-400">{t('tools.noSensitiveCategories')}</p>
) : (
<div className="rounded-md border border-hairline bg-amber-50/40 dark:bg-amber-900/10 divide-y divide-hairline overflow-hidden px-3">
{/* センシティブカテゴリ(ssh / browser 等) */}
{sensitive.map(cat => (
<ToggleRow
key={cat.name}
name={cat.name}
enabled={resolveEnabled(cat, toggledSens)}
note={noteFor(cat.name)}
disabled={!canManage || saveMut.isPending}
disabledReason={!canManage ? readonlyReason : undefined}
onChange={v => setToggledSens(prev => ({ ...prev, [cat.name]: v }))}
/>
))}
{/* 個別センシティブツール(Bash 等) */}
{sensitiveBash.map(tool => (
<ToggleRow
key={tool.name}
name={tool.name}
enabled={resolveSensToolEnabled(tool.name, tool.enabled)}
note={noteFor(tool.name)}
disabled={!canManage || saveMut.isPending}
disabledReason={!canManage ? readonlyReason : undefined}
onChange={v => setToggledSens(prev => ({ ...prev, [tool.name]: v }))}
/>
))}
</div>
)}
</section>
{/* 保存ボタン */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={!canManage || saveMut.isPending || !hasPendingChanges}
className="px-4 py-1.5 rounded-md text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{saveMut.isPending ? t('tools.saving') : t('common:save')}
</button>
{savedState === 'saved' && (
<span className="text-[13px] text-green-600">{t('tools.saved')}</span>
)}
{savedState === 'error' && (
<span className="text-[13px] text-red-600">{t('tools.saveFailedInline')}</span>
)}
{!canManage && (
<span className="text-[13px] text-slate-400">{readonlyReason}</span>
)}
</div>
</div>
</div>
);
}
+19 -10
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SpaceRail } from './SpaceRail';
import { SpaceDetail } from './SpaceDetail';
import { SpaceDetail, type SpaceChatFilter } from './SpaceDetail';
import { WorkerStatusWidget } from '../dashboard/WorkerStatusWidget';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
@@ -13,6 +14,8 @@ interface SpacesPageProps {
onSelectSpaceTask: (id: number) => void;
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
onOpenTask: (id: number) => void;
chatFilter: SpaceChatFilter;
onChatFilterChange: (next: Partial<SpaceChatFilter>) => void;
}
// レール幅の許容範囲。狭すぎると一覧が読めず、広すぎると詳細を圧迫するため上下限でクランプ。
@@ -25,7 +28,8 @@ function clampRailWidth(px: number): number {
return Math.max(RAIL_MIN_PX, Math.min(RAIL_MAX_PX, Math.round(px)));
}
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpacesPageProps) {
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpacesPageProps) {
const { t } = useTranslation('spaces');
const isMobile = useIsMobile();
const [railWidth, setRailWidth] = useLocalStorageState<number>('maestro.spaceRailWidth', RAIL_DEFAULT_PX);
const [collapsed, setCollapsed] = useLocalStorageState<boolean>('maestro.spaceRailCollapsed', false);
@@ -49,13 +53,13 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
data-testid="space-rail-collapse"
onClick={() => setCollapsed(false)}
aria-expanded={false}
title="ワークスペース一覧を開く"
title={t('page.expandRail')}
className="hidden md:flex w-7 shrink-0 flex-col items-center justify-center gap-2 border-r border-hairline bg-surface text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 4l4 4-4 4" />
</svg>
<span className="text-[10px] font-bold uppercase tracking-wider [writing-mode:vertical-rl]"></span>
<span className="text-[10px] font-bold uppercase tracking-wider [writing-mode:vertical-rl]">{t('page.railLabel')}</span>
</button>
) : (
<div
@@ -69,7 +73,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
data-testid="space-rail-collapse"
onClick={() => setCollapsed(true)}
aria-expanded
title="ワークスペース一覧を折りたたむ"
title={t('page.collapseRail')}
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -105,7 +109,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 4l-4 4 4 4" />
</svg>
{t('page.railList')}
</button>
)}
<div className="min-h-0 flex-1">
@@ -116,6 +120,8 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
onSelectSpaceTask={onSelectSpaceTask}
onCreateTask={onCreateTask}
onOpenTask={onOpenTask}
chatFilter={chatFilter}
onChatFilterChange={onChatFilterChange}
/>
</div>
</div>
@@ -127,6 +133,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
// 親が clamp するので、ここでは絶対 X 座標からレール左端基準の幅を計算するだけ。
// latest-ref パターンで drag 中に listener を貼り直さない。
function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: number) => void }) {
const { t } = useTranslation('spaces');
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const draggingRef = useRef(false);
@@ -172,7 +179,7 @@ function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: n
ref={handleRef}
role="separator"
aria-orientation="vertical"
aria-label="ワークスペース一覧の幅を調整"
aria-label={t('page.railResize')}
data-testid="space-rail-resize"
onPointerDown={handlePointerDown}
className={`absolute top-0 right-0 z-10 h-full w-1.5 cursor-col-resize transition-colors hover:bg-slate-300/60 ${active ? 'bg-slate-400/60' : 'bg-transparent'}`}
@@ -185,9 +192,11 @@ function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: n
// レール下部に常駐する折りたたみ式のワーカー/GPU 状況パネル。Tasks ページと同じ
// WorkerStatusWidget を使い、空きスロット(=投入余地)を一目で確認できるようにする。
// 既定は折りたたみで、レール本体の高さを圧迫しない。
// 既定は折りたたみで、レール本体の高さを圧迫しない。開閉状態は localStorage に保存し、
// リロード・再マウントをまたいで維持する(毎回閉じ直す手間をなくす)。
function WorkerStatusFooter() {
const [open, setOpen] = useState(false);
const { t } = useTranslation('spaces');
const [open, setOpen] = useLocalStorageState('space.workerStatus.open', false);
return (
<div data-testid="space-worker-status" className="shrink-0 border-t border-hairline bg-surface">
<button
@@ -197,7 +206,7 @@ function WorkerStatusFooter() {
aria-expanded={open}
className="flex w-full items-center justify-between px-3 py-2 text-[11px] font-bold uppercase tracking-wider text-slate-500 transition-colors hover:text-slate-700"
>
<span> / GPU</span>
<span>{t('page.workerGpu')}</span>
<svg
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
viewBox="0 0 16 16"
Binary file not shown.
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { createSpaceGateway, createPublicAppGateway } from './app-file-gateway';
// read-only は writeFile/deleteFile の「不在」で表現する。認証版は両方を持ち、公開版は
// 持たない。AppRunner はこの有無を見て read-only を判定する(プロパティ存在 = 書き込み可)。
describe('app-file-gateway', () => {
it('space gateway exposes write/delete (read-write)', () => {
const g = createSpaceGateway('s1');
expect(typeof g.writeFile).toBe('function');
expect(typeof g.deleteFile).toBe('function');
});
it('public app gateway has NO write/delete (read-only)', () => {
const g = createPublicAppGateway('tok');
expect(g.writeFile).toBeUndefined();
expect(g.deleteFile).toBeUndefined();
});
it('rawUrl points at the matching backend endpoint for each gateway', () => {
expect(createSpaceGateway('s1').rawUrl('apps/x/a.png')).toBe(
'/api/local/spaces/s1/files/raw?path=apps%2Fx%2Fa.png',
);
expect(createPublicAppGateway('tok').rawUrl('apps/x/a.png')).toBe(
'/api/app-share/tok/files/raw?path=apps%2Fx%2Fa.png',
);
});
});
@@ -0,0 +1,63 @@
// AppFileGateway — AppRunner のファイル I/O を抽象化する。
//
// AppRunner は同じサンドボックス iframe + postMessage ブリッジを、認証版(スペース
// API・書き込み可)と公開版(app-share トークン API・read-only)の両方で使い回す。
// その差分を吸収するのがこの gateway。
//
// READ-ONLY の表現:
// read-only は writeFile/deleteFile プロパティの「不在」で表す(実行時 false を返す
// メソッドではない)。AppRunner は `gateway.writeFile` が undefined かどうかで書き込み
// 経路の有無を判定する。公開版は両メソッドを持たず、ブリッジが read-only エラーを返す。
// サーバ側でも公開 API は GET のみなので、これは UI 側の二重防御に過ぎない。
import {
fetchSpaceFileContent,
fetchSpaceFiles,
getSpaceFileRawUrl,
writeSpaceFile,
deleteSpaceFiles,
fetchAppShareFileContent,
fetchAppShareFiles,
getAppShareRawUrl,
type LocalFileEntry,
} from '../../api';
export interface AppFileGateway {
/** テキスト読み取り(workspace 相対パス)。 */
fetchContent(path: string): Promise<string>;
/** ディレクトリ一覧(workspace 相対パス)。 */
listFiles(dir: string): Promise<{ entries: LocalFileEntry[] }>;
/** raw アセット URLrewriteRelativeAssets / img src 用)。 */
rawUrl(path: string): string;
/** 書き込み(read-only gateway では未定義)。 */
writeFile?(path: string, content: string): Promise<{ ok: boolean }>;
/** 削除(read-only gateway では未定義)。 */
deleteFile?(path: string): Promise<{ ok: boolean }>;
}
/** 認証版(スペース API)。write/delete あり。 */
export function createSpaceGateway(spaceId: string): AppFileGateway {
return {
fetchContent: (path) => fetchSpaceFileContent(spaceId, path),
listFiles: (dir) => fetchSpaceFiles(spaceId, dir),
rawUrl: (path) => getSpaceFileRawUrl(spaceId, path),
async writeFile(path, content) {
await writeSpaceFile(spaceId, path, { content });
return { ok: true };
},
async deleteFile(path) {
await deleteSpaceFiles(spaceId, [path]);
return { ok: true };
},
};
}
/** 公開版(app-share トークン API)。read-only=write/delete は未定義。 */
export function createPublicAppGateway(token: string): AppFileGateway {
return {
fetchContent: (path) => fetchAppShareFileContent(token, path),
listFiles: (dir) => fetchAppShareFiles(token, dir),
rawUrl: (path) => getAppShareRawUrl(token, path),
// writeFile / deleteFile はあえて未定義(read-only)。
};
}
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { buildAppShareDisplayUrl } from './appShareUrl';
// shareUrl は相対パス(/ui/app/:token)。表示時に origin を前置するだけの純関数。
describe('buildAppShareDisplayUrl', () => {
it('prefixes the origin to a relative shareUrl', () => {
expect(buildAppShareDisplayUrl('https://app.example.com', '/ui/app/abc')).toBe(
'https://app.example.com/ui/app/abc',
);
});
it('does not double a trailing slash on the origin', () => {
expect(buildAppShareDisplayUrl('https://app.example.com/', '/ui/app/abc')).toBe(
'https://app.example.com/ui/app/abc',
);
});
it('passes absolute shareUrls through unchanged', () => {
expect(buildAppShareDisplayUrl('https://app.example.com', 'https://other/x')).toBe(
'https://other/x',
);
});
});
+8
View File
@@ -0,0 +1,8 @@
// 公開アプリ共有リンクの表示 URL 組み立て(純関数)。
//
// サーバの share API は shareUrl を相対パス(/ui/app/:token)で返す。表示・コピーの
// ためにブラウザの origin を前置する。既に絶対 URL(scheme 付き)ならそのまま返す。
export function buildAppShareDisplayUrl(origin: string, shareUrl: string): string {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(shareUrl)) return shareUrl; // already absolute
return `${origin.replace(/\/+$/, '')}${shareUrl.startsWith('/') ? '' : '/'}${shareUrl}`;
}
+2
View File
@@ -449,6 +449,7 @@ function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: s
return (
<div className="border border-hairline rounded-lg p-4">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">{t('byUser.title')}</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] text-slate-400 text-left">
@@ -475,6 +476,7 @@ function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: s
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -407,9 +407,9 @@ function ConnectionRow(props: ConnectionRowProps) {
<span className="text-sm font-semibold text-slate-900 truncate">{c.label}</span>
<ScopeBadge owner={c.ownerId} />
<HostKeyBadge verified={verified} pending={pending} />
{disabled && <Badge color="red">{c.disabledByAdmin ? 'admin-disabled' : 'disabled'}</Badge>}
{c.allowRemoteUnrestricted && <Badge color="amber">remote: unrestricted</Badge>}
{c.allowPrivateAddresses && <Badge color="amber">private addrs</Badge>}
{disabled && <Badge color="red">{c.disabledByAdmin ? t('ssh.row.adminDisabled') : t('ssh.row.disabled')}</Badge>}
{c.allowRemoteUnrestricted && <Badge color="amber">{t('ssh.row.remoteUnrestricted')}</Badge>}
{c.allowPrivateAddresses && <Badge color="amber">{t('ssh.row.privateAddrs')}</Badge>}
</div>
<div className="text-2xs text-slate-600 font-mono mt-1 truncate">
{c.username}@{c.host}:{c.port}
@@ -517,9 +517,10 @@ function ScopeBadge({ owner }: { owner: string | null }) {
}
function HostKeyBadge({ verified, pending }: { verified: boolean; pending: boolean }) {
if (pending) return <Badge color="amber">host-key pending</Badge>;
if (verified) return <Badge color="emerald">host-key verified</Badge>;
return <Badge color="slate">host-key untested</Badge>;
const { t } = useTranslation('userfolder');
if (pending) return <Badge color="amber">{t('ssh.hostKey.pending')}</Badge>;
if (verified) return <Badge color="emerald">{t('ssh.hostKey.verified')}</Badge>;
return <Badge color="slate">{t('ssh.hostKey.untested')}</Badge>;
}
function Badge({ color, children }: { color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red'; children: React.ReactNode }) {
+174
View File
@@ -12,6 +12,180 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
## 2026-06-25 — 最近追加した画面が英語表示に対応
表示言語を English に切り替えたとき、新しめの画面が日本語のまま残っていました。ワークスペース(一覧・詳細・メンバー・カレンダー・ツール設定・アプリ)、ファイル操作(ツールバー・並べ替え・移動ダイアログ・プレビュー)、新規作成時のワークスペース選択などを英語化し、言語設定に合わせて切り替わるようにしました。言語は **設定 → 環境設定** で選べます。
## 2026-06-25 — delegate ライブコンソール
delegate サブエージェントの作業中に、チャット欄でその出力がリアルタイムに文字ストリーム表示されるようになりました。「いま何をしているか」がメインエージェントと同じ見え方で分かります。完了後の記録はこれまで通り「概要 > サブ実行」に残ります。
## 2026-06-25 — ファイル一覧でフォルダも選択・削除・ダウンロードできるように
ファイル一覧で、これまでファイルだけに出ていた**チェックボックス・削除・ダウンロード**を、フォルダにも出すようにしました。
- **フォルダの削除**: フォルダを中身ごと削除できます(取り消せないので確認ダイアログが出ます)。
- **フォルダの選択**: チェックを付けて複数まとめて削除・ダウンロードできます。
- **フォルダのダウンロード**: フォルダを zip にまとめてダウンロードできます(中のサブフォルダ・ファイルも構造ごと入ります)。
ワークスペースの足場フォルダ(`input` / `output` / `logs` / `apps` / `readonly` など)は削除できません(消すと仕組みが壊れるため保護しています)。これらも zip ダウンロードは可能です。
## 2026-06-25 — スマホ・タブレットでファイル操作ができるように(タッチ対応)
スマートフォンやタブレットの画面で、ファイルの **ダウンロード・削除・選択**ボタンが押せなかった不具合を直しました。これまでこれらのボタンはマウスを重ねたときだけ表示される作りで、タッチ操作の端末では指で重ねる動作がないため、ボタン自体が一生出てこない状態でした。今後はタッチ端末では常に表示されます(マウスの画面ではこれまでどおり、項目に重ねたときに現れます)。選択ボタンが見えるようになったことで、複数選択して一括で移動する操作もスマホから使えます。あわせて、ファイル一覧やスペースのチャット画面に並ぶ各種の操作ボタンも同じ要領でタッチから使えるようにしました。また、使用量・ゲートウェイのキー一覧などの横長の表が、狭い画面でも横スクロールで全部見られるようになりました。
## 2026-06-25 — サブエージェント(delegate)の実行が見えるように
タスク詳細の **概要(Overview)タブ**に「サブ実行」欄が出て、delegate で起動したサブエージェントの実行が一覧されるようになりました。各サブが何回ツールを使い、成功/中断したか、内部で何をしたか(展開で詳細)を追えます。delegate だけを使ったタスク(SNS 深掘りスイープなど)でも表示されます。これまで delegate のサブ作業はファイル成果物以外は追いにくい状態でしたが、第一級で可視化されます。実行中はほぼリアルタイムで進捗が更新され、カードを開くとサブの動き(ツール呼び出しなど)が走っている間も追えます。経過時間も実行中はライブで進みます。
## 2026-06-25 — SNS 深掘りスイープを追加
タイムラインの投稿をまとめて、1件ずつ丁寧に深掘りできるようになりました。
- **SNS 深掘りスイープ**: 「タイムラインのツイートを1件ずつまとめて深掘りして」のような依頼で、選んだ投稿それぞれを個別の調査担当(サブエージェント)がクリーンな状態から深掘りします。件数が多くても後半まで品質が落ちにくく、最後に1本の統合レポート(各投稿の詳細へのリンク付き)にまとめます。
## 2026-06-25 — サブタスク表示・設定リンク・カレンダー操作の不具合修正
監査で見つかった不具合をまとめて直しました。
- **サブタスク一覧が開けなくなる不具合**: 成果物ファイルや子タスクを持つサブタスクを表示するとパネルが落ちていたのを修正しました。
- **設定の直リンクが効かない不具合**: 「組織」と「HTTPS / TLS」の設定画面を開いた状態でリロードやブックマークをすると別の画面に飛んでしまう問題を直し、URL から直接開けるようにしました。使われていない設定リンク(notes)も整理しました。
- **エージェントがカレンダーに予定を書けない不具合**: ワークスペースのツール設定の見直し以降、エージェント向けのカレンダー追加・一覧ツールが内部で無効になっていたのを復旧しました(→[カレンダー](./22-calendar.md))。
- **ワークスペースのツール設定で Bash を有効にしても保存されない不具合**: 「センシティブなツール」の Bash をオンにして保存しても、すぐオフに戻ってしまう問題を直しました。ssh やブラウザと同じく、オンの状態がそのまま保存されます。
- **ワークスペース・アプリ作成で「テンプレートが見つからない」「ブリッジが動かない」不具合**: アプリ作成エージェントがひな型テンプレートを参照できず、ファイル読み書きの仕組み(ブリッジ)も自前で書いて失敗することがありました。テンプレートをワークスペースに用意し、正しく動くブリッジ実装を必ず引き継ぐように直したので、フォーム入力やデータ表示などのミニアプリが安定して作れます(→[ワークスペース・アプリ](./20-workspace-apps.md))。
## 2026-06-24 — 続けて指示したとき過去のやり取りを丸ごと引き継ぐ
同じワークスペースのチャットに続けて指示を送ったとき、これまでは直前の結果だけを引き継いでいました。今後は、それまでの会話のやり取り全体を引き継いで応答します。長くなりすぎた場合は自動で要約してから続けます。
## 2026-06-24 — 長い作業でも会話の文脈を保持
ステップ(movement)をまたいでも会話の流れを保ったまま作業を続けられるようになりました。これまではステップが変わるたびに文脈の大部分がリセットされていましたが、今後は1つの連続した会話として進み、必要に応じて自動で圧縮されます。作業の経緯は各ワークスペースの `logs/transcript.jsonl` に記録されます。
## 2026-06-24 — `source/` `skills/` フォルダを保護、構造フォルダ内の整理を改善
エージェントが自動で作る `source/`(取得した情報源)と `skills/`(スキルの作業コピー)を、`input/` `output/` などと同じ**構造フォルダ**として保護しました。フォルダ自体の移動・リネームはできなくなり、ファイルタブで「ソース」「スキル」のバッジが付きます。あわせて、構造フォルダ**の中のファイル**(例: `output/` の成果物)を別の場所へ移動・リネームできなかった不具合を直しました。中身の整理は自由、フォルダの骨組みは守る、という形に揃えました(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — ファイルタブをドラッグで整理できるように(移動)
ファイルタブを、ふだんのファイル管理アプリのように扱えるようにしました。**ファイルやフォルダをドラッグして別のフォルダへドロップ**したり、上部パンくずの階層名へ落として上の階層へ移したりできます。チェックで複数選んだファイルは、選択バーの**「移動」ボタン**から移動先を選んで一括移動できます。名前変更も、これまでの一覧表示に加えて**アイコン表示**でも使えるようになりました。`input/` `output/` などの構造フォルダ自体は移動できませんが、その中へファイルを入れるのは問題ありません(オーナー / 編集者のみ。→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — スクリプト作業(Bash / Python)の進め方をエージェントに指示
シェルを使えるタスクで、エージェントがスクリプト作業をより手堅く進めるようにしました。データ処理は Python(pandas などは導入済み)でまとめて書く、コマンドは非対話で実行する、大きな出力はいったんファイルに書いてから要点だけ確認する、といった指針をシェル系ツールが使えるときだけ指示として渡します。巨大な実行結果でコンテキストを圧迫したり、同じコマンドの失敗を繰り返したりしにくくなります。
## 2026-06-24 — 会話の圧縮時に直近のやり取りをより多く原文のまま残す
長い会話でコンテキストが逼迫すると、古いやり取りを要約に置き換えて圧縮します。このとき直近のやり取りは原文のまま残しますが、これまでは「直近 N 往復」と固定の回数で決めていました。これを「直近のトークン量が一定量に収まる範囲」で残す方式に変更。1 往復が短いときは原文で残る往復が増え、直近の文脈がより多く生き残ります(長い往復のときの挙動は従来どおり)。残す量は設定(`safety.history_summarization.preserve_recent_budget`)で調整できます。
## 2026-06-24 — コンテキスト上限が大きいモデルで会話をより長く保持
コンテキスト上限が大きいモデル(数十万トークン以上)で、長い会話の生履歴をこれまでより長く保てるようにしました。以前は上限の 8 割に達した時点で要約・圧縮を始めていたため、上限が大きいほど残り 2 割の余白を使わずに捨てていました。圧縮を始める基準を「上限の一定割合」から「上限から一定量を差し引いた値」(既定 3.2 万トークン分の余白を確保)に変更。小〜中規模のモデルの挙動は変わりません。余白の量は設定(`safety.history_summarization.reserve_cap_tokens`)で調整できます。
## 2026-06-24 — ファイルタブでフォルダ作成・名前変更ができるように
ファイルタブを自分で整理できるようにしました。**「新規フォルダ」ボタン**で現在の場所にフォルダを作れ、各ファイル・フォルダにマウスを重ねると出る**名前変更**アイコンでリネームできます(オーナー / 編集者のみ)。`input/` `output/` などエージェントの仕事用フォルダは、誤って壊さないよう名前変更・移動の対象外です。既存ワークスペースに `readonly/` が無い場合も、この「新規フォルダ」で自分で追加できます。あわせて、ファイルタブ上部のパス表示が二重になっていたのを 1 つ(パンくず)に整理しました(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — エージェントが触らない「閲覧のみ」フォルダを追加
編集されたくないファイルを安心して置けるよう、各ワークスペースに `readonly/` フォルダを追加しました。**ここに置いたファイルは、エージェントが読むことはできても、書き換え・上書き・削除はできません**(ツール側で拒否されます)。あわせて Files タブの各フォルダに役割バッジ(入力 / 成果物 / 作業ログ / アプリ / 🔒 閲覧のみ)を付け、エージェントが書き込める場所と書き込めない場所が一目で分かるようにしました(→[ワークスペースとメンバー](./21-workspaces.md))。
> 注意: シェル(Bash)経由の書き込みまでは完全には防げません。エージェントの通常の編集(Write/Edit)は確実にブロックします。
## 2026-06-24 — 公開範囲で「何が・誰に共有されるか」を表示
タスクの公開範囲(非公開 / 組織 / 公開)を変えるとき、選択欄の下に**実際に共有される内容のプレビュー**を出すようにしました。共有されるもの(タスク詳細・コメント・ファイル・サブタスク)と、**共有されないもの(AGENTS.md・メモリ・カレンダー)**、閲覧者が読み取り専用であること、「公開」はログイン済みユーザー向け(未ログインには非公開)であることを明示します。各選択肢にホバー説明も付きました(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — つかんだ「コツ」をその場で書きためるように
エージェントが、タスクの途中で試行錯誤の末につかんだコツや回避策を、タスク完了を待たずその場で永続側(メモリ、または案件の AGENTS.md)に記録するようになりました。「最初に失敗したやり方」と「最終的にうまくいったやり方」がセットで残るので、次回以降の同じワークスペースの作業で同じ壁にぶつかりにくくなります。AGENTS.md に「メモリを育てて」「コツを書きためて」と書いておくと、より積極的に記録します(→[メモリ](./12-memory.md))。
## 2026-06-24 — 長い作業でのコンテキスト要約をより正確に
会話が長くなってコンテキストが上限に近づくと、エージェントはそれまでの経緯を要約して圧縮します。この要約の指示を見直し、**ファイルパス・コマンド・エラー文字列などを言い換えずそのまま残す**よう徹底しました。あわせて、通常の圧縮時と上限到達時の2系統で食い違っていた要約様式を1つに統一しました。長い作業でも目的・決定・次の一手が崩れにくくなります(内部改善)。
## 2026-06-24 — BOM 付きファイルの文字化けを修正
Windows のメモ帳などが付ける **UTF-8 BOM**(ファイル先頭の見えない印)が原因で、エージェントがファイルを読むと先頭が文字化けして処理を誤ることがありました。ファイルを読み込むツール(Read・Grep・ターミナル出力)の段階で、この先頭の印を自動で取り除くようにしました。ディスク上のファイルそのものは変更しません(→[ツール](./16-tools.md))。
## 2026-06-24 — ワークスペースごとにツールの利用可否を設定できるように
ワークスペースの **設定 → ツール** タブで、そのワークスペース内のエージェントが使えるツールを制御できるようになりました。ファイル読み書き・Web・Office などの安全な標準カテゴリはデフォルトで有効で、カテゴリ単位でオフにできます。**Bash(任意コマンド実行)・ブラウザ操作・SSH・外部 MCP** などセンシティブなものはデフォルトで無効で、必要なときだけオーナーが明示的にオンにします。これにあわせて、どのツールを使えるか・ファイルを書き換えてよいかは **ワークスペース単位**で決まるようになりました(チャットの手順ステップ側では制限しません)。変更できるのはオーナーのみです(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — 画面まわりの細かな使い勝手を改善(4 件)
ユーザーからの声をもとに、画面まわりを 4 つ直しました。**コンテキスト残量**を、概要タブまでスクロールしなくても確認できるよう、チャット入力欄のすぐ上に細いバーとして常時表示します。残りトークン数と使用率がひと目で分かります。レール下部の**ワーカー / GPU パネル**の開閉状態を覚えるようにしたので、開いたのに再読み込みで閉じてしまう、ということがなくなりました。スマホなど狭い画面でも、チャット画面の**ペット**が消えずに表示されます。管理者が全ユーザーのワークスペースを見ているときは、自分が所有するワークスペースに「**自分**」バッジが付いて見分けやすくなりました(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — 旧タスクリンクが「そのタスクのワークスペース」で開くように
タスクタブ廃止にともなう旧アドレス(`?task=...` を含むブックマークや通知リンク)の読み替えを賢くしました。これまでは一律で個人ワークスペースに開いていましたが、そのタスクが **案件ワークスペースのもの**なら、ちゃんとその案件ワークスペースで開くようになりました。通知やカレンダーから開いたタスクも同様に、正しいワークスペースに着地します(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — チャット開始と成果物の在りかを画面で案内
初めてのワークスペースで、次に何をすればよいかが画面で分かるようにしました。チャットがまだ 1 つもないときは、「『+ 新規』からエージェントに依頼を送れます。ファイルタブに資料を置いておくと、依頼の入力として使われます。成果物は ファイルタブの output/ に保存されます。」という控えめな案内を出します。あわせて、ファイルタブの output/ がまだ空のときは「エージェントの成果物は、この output/ に保存されます。」と添えて、できあがったファイルの探し方が分かるようにしました(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — 空の画面に「次の一手」を表示
初めて開いたときの空の画面を、次にどうすればよいか分かるように少しやさしくしました。まだワークスペースを選んでいないときは、これまでの一文だけの案内に代えて「ワークスペースを選んでください」という見出しと、「左の一覧から開くか、上の『新規』ボタンで新しいワークスペースを作成できます。」という補足を出します。また、ワークスペースの **Files** にファイルが 1 つもないときは、「ここに置いたファイルは、このワークスペースのチャットでエージェントの入力になります。」という控えめな一文を添えて、ファイルを置く意味が分かるようにしました(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — 「タスク」タブを廃止し、ワークスペースに一本化
上部の **「タスク」タブをなくしました**。これからの作業はすべて **ワークスペース** から行います。起動すると、まず自分の **個人ワークスペース** が自動で開くので、空の画面に放り出されることはありません。案件の作業は、同じ **ワークスペース** タブの一覧から該当のワークスペースを選んで開きます。ファイルや設定も、これまでタスクページのサブタブにあったものがそのまま各ワークスペースの中にあります。以前のタスクページのアドレス(`?page=tasks` の付いたブックマークや共有リンク)は、開いたときに自動で個人ワークスペースへ読み替えるので、そのまま使えます(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — 置いたファイルをエージェントが自動で認識
ワークスペースに先に置いたファイルを、チャット開始時にエージェントが自動で認識するようになりました。チャットを始めると「ワークスペースの既存ファイル一覧」がエージェントへ渡るので、`input/` に入れなくても置いたファイルが入力として使われます。新しく作ったワークスペースには `input/``output/` フォルダが最初から用意されます。また、チャット作成時に **一時的** モードを選ぶと、成果物が使い捨てでファイルが引き継がれない旨の警告が出るようになりました(通常は **永続(既定・推奨)** のまま使ってください)(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-24 — ワークスペースのチャット絞り込みが URL に残るように
ワークスペースのチャットタブで設定した絞り込み(キーワード検索・状態の絞り込み・並び順・自分/他のメンバーの切り替え)が、ページのアドレス(URL)に保存されるようになりました。これまでは別のタブに移って戻ったり、ブラウザを再読み込みしたりすると絞り込みが消えていましたが、これからは保持されます。その状態のままアドレスをブックマークすれば、次に開いたときも同じ絞り込みで表示されます。タスクページの絞り込みとは別々に管理されるので、お互いに影響しません(→[ワークスペース](./21-workspaces.md))。
## 2026-06-24 — ワークスペース・アプリを作ると自動テスト+スターターテンプレートを追加
「ワークスペース・アプリを作って」と頼むと、作成後にヘッドレスブラウザで **自動 E2E テスト** が走るようになりました。アプリが実際に起動してファイルを読み書きできることを確認してから完了を報告するので、「動かないアプリが仕上がる」ケースが大きく減ります。テストに引っかかった場合はエージェントが原因を特定して修正します。あわせて、用途別の **スターターテンプレート**(メモエディタ・データビューア・フォーム入力・ダッシュボード)を 4 種類用意しました。エージェントはこれを土台にして作るため、初回から動く完成度で届きます(→[ワークスペース・アプリ](./20-workspace-apps.md))。
## 2026-06-24 — 共有タスクページのファイル一覧と進捗表示を本体と統一
共有リンクで開くページの見え方を、本体のタスク詳細にさらに近づけました。Files(`output/`)は本体と同じファイル窓になり、アイコン表示と詳細表示(更新日時・サイズ)の切り替え、列見出しでの並べ替え、フォルダの行き来、プレビュー・ダウンロードがそのまま使えます。Overview の進捗・結果も、本体のチャットと同じく **ステップ(movement)ごとにまとまり、折りたためる表示**になりました。見出しを開くと、そのステップの途中経過やツール実行の詳細を確認できます。共有ページは引き続き **read-only**(編集・削除・アップロードはできません)で、公開されるのは `output/` のファイルだけです(→[結果を受け取る](./04-results.md))。
## 2026-06-23 — 共有タスクページの中身を本体と統一(サブタスクも開けるように)
タスクの共有リンク(`/ui/shared/...`)で開くページを、本体のタスク詳細と同じ部品で作り直しました。これまで共有ページでサブタスクの「▼」を開いても活動ログやファイルが表示されないことがありましたが、共有専用の取得経路を用意したことで、ログインなしでもサブタスクの進捗・成果物ファイルがそのまま見えるようになりました。共有ページは引き続き **read-only** で、タイトル編集・フィードバック・削除・再共有などの操作ボタンは出ません。公開されるのは Overview(結果・サブタスク)と Files(`output/`)だけで、内部向けの Trace/ブラウザ/コンソールは出ません(→[結果を受け取る](./04-results.md))。
## 2026-06-23 — ワークスペース・アプリを公開リンクで共有(read-only)
作ったワークスペース・アプリを、ログイン不要の公開リンクで共有できるようになりました。「アプリ」タブの各アプリカードから「公開リンクを作成」を押すと `/ui/app/...` の URL ができ、相手はログインせずブラウザでそのアプリを開けます。公開リンクは **read-only(書き込み・削除はできません)** で、見えるのはそのアプリと `output/` のファイルだけに限られます。発行・失効はワークスペースの管理者(オーナー)が行え、「失効」を押せばその URL はすぐ使えなくなります(→[ワークスペース・アプリ](./20-workspace-apps.md))。
## 2026-06-23 — X(旧Twitter)ツールが復旧、ホームタイムラインにも対応
X 側の仕様変更で動かなくなっていた検索・ユーザー投稿の取得を、取得方法を切り替えて復旧しました。あわせて、ログイン中アカウントの**ホームタイムライン**(おすすめ/フォロー中)を取得する機能も使えるようになりました。`XSearch`(検索)・`XUserPosts`(ユーザー投稿)・`XPostDetail`(投稿の詳細)・`XTimeline`(ホームタイムライン)が利用できます。X の認証 Cookie の設定が必要な点は従来どおりです(→[ツール](./16-tools.md))。
> 補足: X はたびたび仕様を変えるため、再び取得できなくなることがあります。その場合は取得用ツールの更新で復旧します(管理者向け手順は別途)。
## 2026-06-23 — ファイル一覧に詳細表示(更新日時・サイズ)を追加
ファイル一覧に、Windows エクスプローラーのような「詳細表示」を追加しました。一覧右上のボタンでアイコン表示と詳細表示を切り替えられます。詳細表示では名前・更新日時・サイズが一覧で見え、列見出しをクリックすると並べ替え(再クリックで昇順・降順の反転)ができます。選んだ表示と並び順はブラウザに記憶されます。タスク・ワークスペース両方のファイル窓で使えます(→[結果を受け取る](./04-results.md))。
## 2026-06-23 — ワークスペースの名前・色・説明を編集+説明をヘッダーに表示
ワークスペースの編集が名前だけでなく **ブランド色・説明** にも広がりました。ヘッダーのタイトル横の鉛筆ボタンから、新規作成と同じダイアログで名前・色・説明をまとめて変更できます。入力した説明は、ワークスペースを開いたときにタイトルのすぐ右へ薄字で表示されるようになり、何のための場所か一目で分かります(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-23 — ワークスペース一覧を見やすく(統合 / 個別スペースの色帯区分)
左のワークスペース一覧を整理しました。グループ名を **統合スペース**(あなたの作業が集まる個人ワークスペース)と **個別スペース**(案件ごとの作業場)に変え、左端の色帯で一目で区別できます。常時出ていた「PRIVATE」表示は、既定の非公開では省略し(**組織 / 公開** のときだけ表示)、その分「● N 実行中」バッジを行の右端へ寄せて、ワークスペース名を広く読めるようにしました(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-23 — 予定に終了時刻を指定できるように+横断カレンダーの見え方を統一
予定の **開始時刻だけでなく終了時刻**も指定できるようになり、「9:00–10:30」のように時間帯で登録・表示できます(終了時刻は開始時刻を入れたときに指定でき、単日の予定では開始以降のみ)。あわせて、上部の **カレンダー** タブ(ワークスペース横断ビュー)の見え方を、各ワークスペース内のカレンダーと揃えました。複数日にまたがる予定が色付きの横棒でつながって表示され、終了日・時間帯の表示や、スマートフォンの上下 2 分割も個別カレンダーと同じになります(→[カレンダー](./22-calendar.md))。
## 2026-06-23 — エージェントが足りないツールをその場で要求・承認
エージェントが作業中に「この movement にないツールが必要」と判断したとき、チャットに承認カードが出て、その場で「許可/拒否」を選べるようになりました。許可するとそのツールがこのタスクで使えるようになり、エージェントがそのまま続行します。拒否すると、そのツール無しで進みます。要求はすべて記録されるので、どの Piece でどんなツールが求められたかを後から確認でき、`allowed_tools` / `shared_tools` の設定漏れを見つけて直しやすくなります(→[Piece を選ぶ・作る](./05-pieces.md))。
## 2026-06-23 — Excel・PowerPoint をそのままプレビュー
これまでバイナリとして文字化けしていた Excel.xlsx / .xlsm)と PowerPoint.pptx / .ppt)が、ファイルプレビューで中身を確認できるようになりました。Excel はシートごとの表(複数シートはタブで切り替え)、PowerPoint は各スライドを画像にして見た目どおりに表示します。タスク・ワークスペースのどちらのファイル窓でも開けます。PowerPoint の画像化にはサーバー側の変換エンジン(LibreOffice)が必要で、未導入の環境ではダウンロード案内に切り替わります(→[結果を受け取る](./04-results.md))。
## 2026-06-23 — 右側パネルをワーカー / ノード(GPU)表示に集約
右側の情報パネルにあった自由ウィジェット機能(Markdown メモなどをタブで増やせる仕組み)を廃止しました。パネルは「ワーカー」と「ノード(GPU)」の 2 つの固定タブだけになり、稼働状況をすぐ確認できます。ウィジェットを書き込むエージェント用ツール(UpdateDashboardWidget)も同時に削除しました。これまで作っていた Markdown ウィジェットの内容は表示されなくなります。
+1 -2
View File
@@ -58,8 +58,7 @@ MAESTRO に頼める代表的な仕事です。
| タブ | 何をする場所か |
|---|---|
| タスク | 個人ワークスペースでタスクを作り、実行を見る(既定の入口 |
| ワークスペース | 個人 / 案件のワークスペースを切り替え、メンバー・ファイル・設定を管理する(→[ワークスペースとメンバー](./21-workspaces.md) |
| ワークスペース | 既定の入口。個人 / 案件のワークスペースを切り替え、その中でタスク(チャット)を作り、ファイル・メンバー・設定を管理する(→[ワークスペースとメンバー](./21-workspaces.md) |
| カレンダー | ワークスペースの活動を日付ごとに振り返る(タスク・予定・変更ファイル)(→[カレンダー](./22-calendar.md) |
| スケジュール | 定期実行を登録・管理する(→[スケジュール実行](./06-schedules.md) |
| Pieces | piece(タスクの型)の一覧・作成(→[piece を使う・作る](./05-pieces.md) |

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