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

This commit is contained in:
oss-sync
2026-06-23 06:38:48 +00:00
parent 6a2f2cc736
commit 29ccaf1e92
377 changed files with 31028 additions and 8994 deletions
+53
View File
@@ -0,0 +1,53 @@
# E2E LOCAL-AUTH harness config.
#
# Loaded via AAO_CONFIG by ui/playwright.auth.config.ts. Unlike the auth-off
# harness (ui/playwright.config.ts, no AAO_CONFIG => synthetic 'local' user),
# this boots the orchestrator with LOCAL LOGIN ENABLED so real users sign in
# through the browser. This is the ONLY way to exercise the shared-space member
# invite + avatar features end-to-end: they depend on real user identities
# (owner row, /api/users/pickable, space_members membership) that simply do not
# exist under no-auth.
#
# - DB_PATH / WORKTREE_DIR come from env (the playwright config mks fresh temp
# dirs and passes them to BOTH the webServer and the seeding code), so no
# storage paths are pinned here.
# - session_secret is inlined (fixed throwaway value) so the server never
# generates a key file under data/ — keeps the repo clean and the run
# deterministic.
# - secure_cookie: false is REQUIRED — over plain http://127.0.0.1 a secure
# cookie is never sent back, so login would appear to fail.
auth:
session_secret: "e2e-fixed-secret-do-not-use-in-prod-0123456789"
secure_cookie: false
primary_provider: local
local:
enabled: true
allow_signup: false
bootstrap_admin:
email: "[email protected]"
password: "AdminPass123!"
# A single ENABLED worker pointed at an UNREACHABLE endpoint. It must be enabled
# with a non-empty endpoint + model + execution role so the browser setup
# wizard's needsSetup gate (scripts/setup-lib.mjs isLlmConfigured, evaluated over
# the resolved provider.workers) is satisfied — otherwise the SPA renders the
# first-run wizard instead of the real app and no spaces UI is reachable.
#
# NOTE: this MUST be the legacy `provider.workers` block (the runtime-effective
# array that worker.ts and computeNeedsSetup read). The v2 `llm.workers` block is
# NOT mapped back into provider.workers at load time — a config that only sets
# `llm.workers` falls back to the synthetic model-less `default` worker and
# needsSetup stays true.
#
# Reachability is irrelevant: the member/avatar tests never run a job, so the
# queued job that would need this LLM is never picked up.
provider:
base_url: http://127.0.0.1:1/v1
model: e2e-noop
workers:
- id: e2e-noop
endpoint: http://127.0.0.1:1/v1
model: e2e-noop
roles: [auto, fast, quality]
enabled: true
+333
View File
@@ -0,0 +1,333 @@
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 shared-space E2E ───────────────────────────────────────────────
//
// Unlike ui/e2e/spaces.spec.ts (auth OFF => synthetic 'local' user, empty member
// list, /api/users/pickable === []), this harness boots the orchestrator with
// LOCAL LOGIN ENABLED (ui/playwright.auth.config.ts passes AAO_CONFIG). Real
// users sign in through the browser, so the member invite + avatar features —
// which depend on real identities (owner row, pickable users, space_members) —
// can finally be exercised end-to-end. They are unreachable in the auth-off suite.
//
// The bootstrap admin ([email protected], id='local', role admin) is auto-seeded
// at startup. Member users (alice/bob) are seeded into the SAME temp DB in
// beforeAll, using the BUILT Repository — by the time tests run the webServer is
// up, so schema + migrations exist.
const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// __dirname is <repo>/ui/e2e-auth → repo root is two levels up.
const repoRoot = resolve(__dirname, '..', '..');
// Same deterministic DB path the webServer uses (ui/playwright.auth.config.ts
// builds it identically: <tmpdir>/maestro-e2e-auth/e2e-auth.db). Computing it
// independently — rather than importing the config — keeps the spec decoupled
// from Playwright's config-resolution internals while guaranteeing both open
// the SAME SQLite file.
const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db');
const ADMIN = { email: '[email protected]', password: 'AdminPass123!' };
const ALICE = { email: '[email protected]', password: 'AlicePass123!', name: 'Alice' };
const BOB = { email: '[email protected]', password: 'BobPass123!', name: 'Bob' };
// Space-OWNING admin used to create + manage shared spaces. NOTE: this is a
// SEPARATE seeded admin with a REAL uuid — NOT the bootstrap admin (whose id is
// literally 'local'). The space create handler nulls owner_id when the creator's
// id === 'local' (it treats that as the no-auth synthetic user), so a space
// created by the bootstrap admin has NO owner row and the avatar stack (members
// >= 2) can never form. A non-'local' owner is required to exercise the feature.
const MANAGER = { email: '[email protected]', password: 'ManagerPass123!', name: 'Manager' };
// Same benign-path tolerance as the auth-off suite, MINUS /api/auth/me (here it
// is mounted and returns 200). With auth on, the per-task console/browser probes
// and MCP/SSH/org probes still 401/404 benignly.
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;
const BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
// The space members GET is auth-gated. During the logout→login handoff (test 3),
// the header avatar query can fire ONE request after the cookie is cleared but
// before the new session lands → a transient 401. Only a 401 (unauthenticated)
// is benign here; a 403 (authenticated-but-forbidden) would be a real authz bug
// and is NOT excused. The avatar assertion still proves the eventual 200.
const SPACE_MEMBERS_RE = /\/api\/local\/spaces\/[^/]+\/members$/;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
const transientMembers401 = res.status() === 401 && SPACE_MEMBERS_RE.test(pathname);
if (
!BENIGN_ASSET.test(pathname) &&
!BENIGN_PATHS.has(pathname) &&
!BENIGN_PATH_RE.test(pathname) &&
!transientMembers401
) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
/** Log a real user in via the browser form. Success → 302 `/` → 302 `/ui` →
* SPA at `/ui/`. Failure → `/auth/login?error=…`. We wait for the redirect
* chain to settle off the login page, assert we landed on the SPA (not back on
* login with an error), then ensure the SPA route is loaded. */
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');
}
async function logout(page: Page) {
await page.goto('/auth/logout');
}
/** Open Spaces, create a case space with a unique title, select it, and return
* the detail locator + the resolved space id. Mirrors the auth-off helper. */
async function createAndOpenCaseSpace(page: Page, label: string) {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRow).toBeVisible();
const spaceId = await caseRow.getAttribute('data-space-id');
expect(spaceId).toBeTruthy();
await caseRow.click();
const detail = page.getByTestId('space-detail');
await expect(detail).toBeVisible();
return { detail, caseTitle, rail, spaceId: spaceId as string };
}
/** Navigate the already-open space to 設定 → メンバー and return the panel. */
async function openMembersPanel(page: Page) {
await page.getByTestId('space-tab-settings').click();
await page.getByTestId('space-settings-nav-members').click();
const panel = page.getByTestId('space-members-panel');
await expect(panel).toBeVisible({ timeout: 15_000 });
return panel;
}
/** Open the invite picker, pick `name` from the candidate list, and add them
* (role defaults to 編集者/editor). Asserts the real-user list shows (NOT the
* no-auth hint) before selecting. */
async function inviteMember(page: Page, name: string) {
const panel = page.getByTestId('space-members-panel');
await panel.getByTestId('space-member-invite').click();
const picker = panel.getByTestId('space-member-picker');
await expect(picker).toBeVisible({ timeout: 15_000 });
// Real active-user pool → list, not the no-auth "認証を有効化すると…" hint.
await expect(picker.getByText('認証を有効化するとワークスペースを共有できます。')).toHaveCount(0);
// Candidate rows are buttons named "<initial> <name> <email>"; scope by role
// + name regex so the name/email substrings don't trip strict mode.
await picker.getByRole('button', { name: new RegExp(name) }).click();
await picker.getByRole('button', { name: '追加' }).click();
}
test.beforeAll(() => {
// The webServer is already up (Playwright waits on its url before running),
// so the schema + migrations + bootstrap admin all exist. Seed members into
// the SAME deterministic DB the server opened.
const dbPath = DB_PATH;
// Import the BUILT Repository (named export `class Repository`).
const { Repository } = require(resolve(repoRoot, 'dist/db/repository.js')) as {
Repository: new (dbPath: string) => {
getUserByEmail: (email: string) => unknown;
createLocalUser: (p: {
email: string;
password: string;
name?: string;
role: string;
status: string;
}) => unknown;
close?: () => void;
};
};
const repo = new Repository(dbPath);
try {
// Member users (role 'user') + a space-owning admin (role 'admin', real id).
const seeds = [
{ ...ALICE, role: 'user' as const },
{ ...BOB, role: 'user' as const },
{ ...MANAGER, role: 'admin' as const },
];
for (const u of seeds) {
if (!repo.getUserByEmail(u.email)) {
repo.createLocalUser({
email: u.email,
password: u.password,
name: u.name,
role: u.role,
status: 'active',
});
}
}
} finally {
repo.close?.();
}
});
// 1. Sanity: auth is actually ON. The bootstrap admin logs in and /api/auth/me
// reports an active admin. (Under auth-off this endpoint 404s.)
test('admin login + bootstrap: /api/auth/me reports active admin', async ({ page }) => {
await login(page, ADMIN.email, ADMIN.password);
const res = await page.request.get('/api/auth/me');
expect(res.ok(), `auth/me status ${res.status()}`).toBeTruthy();
const me = (await res.json()) as { email?: string; role?: string; status?: string };
expect(me.role).toBe('admin');
expect(me.email).toBe(ADMIN.email);
expect(me.status).toBe('active');
});
// 2. THE avatar feature: a (non-'local') owning admin creates a case space,
// invites alice (editor) via the real picker (NON-empty now that alice is a
// real active user), and the header avatar stack appears (members >= 2:
// owner manager + alice).
test('invite alice → member row + header avatars appear', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, MANAGER.email, MANAGER.password);
await createAndOpenCaseSpace(page, 'E2E共有');
const panel = await openMembersPanel(page);
// Before inviting anyone, only the owner (admin) is a member → no avatar stack.
await expect(page.getByTestId('space-member-avatars')).toHaveCount(0);
// Invite alice via the real picker (non-empty now that she is a real active
// user; also asserts the no-auth hint is absent).
await inviteMember(page, ALICE.name);
// Alice now appears as a member row in the panel.
const aliceRow = panel.locator('[data-testid^="space-member-"]').filter({ hasText: ALICE.name });
await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 });
// AND the header avatar stack renders now that the space has >= 2 members.
// We reload first: the header avatar query keys on ['spaceMembers'] while the
// panel invalidates ['space-members'] — different keys, so the header does NOT
// live-refetch after an in-session invite (a real, minor product cache-key
// mismatch — see the harness report). A reload is what a user sees on the next
// navigation; this asserts the actual rendered avatar feature, not a cache race.
await page.reload();
await expect(page.getByTestId('space-detail')).toBeVisible();
await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 });
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 3. Member collaboration: alice logs in, sees the shared space in her rail
// (membership grants visibility), opens it, and the avatar stack renders for
// her too. She is NOT admin and not the owner, so she gets read-only member UI.
test('alice sees + opens the shared space, avatars render', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
// The owning admin creates a fresh shared space and invites alice.
await login(page, MANAGER.email, MANAGER.password);
const { spaceId } = await createAndOpenCaseSpace(page, 'E2E協働');
const panel = await openMembersPanel(page);
await inviteMember(page, ALICE.name);
const aliceRow = panel.locator('[data-testid^="space-member-"]').filter({ hasText: ALICE.name });
await expect(aliceRow.first()).toBeVisible({ timeout: 15_000 });
// Switch to alice.
await logout(page);
await login(page, ALICE.email, ALICE.password);
// The shared space is visible in alice's rail (space_members membership).
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
const sharedRow = rail.locator(`[data-testid="space-row"][data-space-id="${spaceId}"]`);
await expect(sharedRow).toBeVisible({ timeout: 15_000 });
// She can open it (membership grants access — no 403).
await sharedRow.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
// The avatar stack renders for her as well (>= 2 members).
await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 });
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 4. Role + remove: the owning admin changes alice's role to viewer, then
// removes her. The member row disappears and the avatar stack collapses back
// (owner only => members < 2).
test('admin changes alice role then removes her', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, MANAGER.email, MANAGER.password);
await createAndOpenCaseSpace(page, 'E2E権限');
let panel = await openMembersPanel(page);
await inviteMember(page, ALICE.name);
// Resolve alice's member row + her userId from the row testid suffix
// (`space-member-{userId}`; the row wrapper is the first prefix match).
const aliceRow = () =>
page.getByTestId('space-members-panel')
.locator('div[data-testid^="space-member-"]')
.filter({ hasText: ALICE.name })
.first();
await expect(aliceRow()).toBeVisible({ timeout: 15_000 });
const testid = await aliceRow().getAttribute('data-testid');
const aliceId = (testid ?? '').replace(/^space-member-/, '');
expect(aliceId).toBeTruthy();
// Avatars present while she is a member (reload so the header's ['spaceMembers']
// query picks up the invite — see the cache-key note in test 2 / the report).
await page.reload();
await expect(page.getByTestId('space-detail')).toBeVisible();
await expect(page.getByTestId('space-member-avatars')).toBeVisible({ timeout: 15_000 });
// Re-open the panel (reload reset the tab) and change alice editor → viewer.
panel = await openMembersPanel(page);
const roleSelect = panel.getByTestId(`space-member-role-${aliceId}`);
await roleSelect.selectOption('viewer');
await expect(roleSelect).toHaveValue('viewer');
// Remove her (auto-accept the confirm dialog). The panel's own query
// invalidates on the same key, so her row disappears in-session.
page.once('dialog', (d) => d.accept());
await panel.getByTestId(`space-member-remove-${aliceId}`).click();
await expect(panel.getByTestId(`space-member-${aliceId}`)).toHaveCount(0, { timeout: 15_000 });
// After a reload the header avatar stack collapses (owner only => < 2 members).
await page.reload();
await expect(page.getByTestId('space-detail')).toBeVisible();
await expect(page.getByTestId('space-member-avatars')).toHaveCount(0, { timeout: 15_000 });
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
+359
View File
@@ -0,0 +1,359 @@
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 { mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
// ── Local-auth SHARING-SCOPE E2E ──────────────────────────────────────────────
//
// Proves the access-model fix on feat/space-visibility-and-org-picker is scoped
// correctly across THREE personas, end-to-end through the real server with LOCAL
// AUTH on (ui/playwright.auth.config.ts → ui/e2e-auth/config.e2e-auth.yaml):
//
// manager — owns the case space, manages members. role 'user' (NON-admin):
// admins see every user in the picker, which would defeat the
// org-scope assertion. A regular user only sees same-org members.
// member — invited (editor). Shares an org with manager → appears in picker
// and (via space_members) can read the manager-owned chat.
// stranger — never invited, in NO org → must NOT see the space, its chat, or
// appear in the picker.
//
// IDENTITY ISOLATION: this file uses its own *@scope.local emails so it never
// collides with shared-space.auth.spec.ts (which seeds [email protected] as an
// ADMIN into the SAME shared DB). Reusing that admin would silently make the
// picker non-org-scoped.
//
// ORG SEEDING (real, not fallback): orgIds are computed at login (auth.ts
// deserializeUser → resolveOrgIds, union of Gitea cache + local_org_members), so
// seeding a LOCAL org and adding manager+member to it BEFORE they log in makes
// both carry that orgId in their session — exactly what /api/users/pickable
// (repo.listActiveUsersInOrgs) filters on. stranger is left out, so the picker
// excludes them and the invite genuinely works.
//
// CHAT SEEDING: the shared space + a chat task owned by manager are created in
// beforeAll via the BUILT Repository, with a real workspace dir (+ logs/), so the
// files/logs endpoints return 200 for members and 404 for the stranger. Seeding
// in beforeAll (not a test body) keeps all ids deterministic for every test —
// Playwright does not guarantee module-level mutations from one test reach the
// next.
const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const repoRoot = resolve(__dirname, '..', '..');
// Same deterministic DB + worktree dir the webServer opens (playwright.auth.config.ts).
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
const MANAGER = { email: '[email protected]', password: 'MgrPass123!', name: 'ScopeManager' };
const MEMBER = { email: '[email protected]', password: 'MbrPass123!', name: 'ScopeMember' };
const STRANGER = { email: '[email protected]', password: 'StrPass123!', name: 'ScopeStranger' };
// Resolved in beforeAll, reused by every test.
let managerId = '';
let memberId = '';
let strangerId = '';
let sharedSpaceId = '';
let chatTaskId = 0;
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;
const BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
const SPACE_MEMBERS_RE = /\/api\/local\/spaces\/[^/]+\/members$/;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
page.on('pageerror', (err) => fatalErrors.push(`pageerror: ${err.message}`));
page.on('response', (res) => {
if (res.status() >= 400) {
const { pathname } = new URL(res.url());
const transientMembers401 = res.status() === 401 && SPACE_MEMBERS_RE.test(pathname);
if (
!BENIGN_ASSET.test(pathname) &&
!BENIGN_PATHS.has(pathname) &&
!BENIGN_PATH_RE.test(pathname) &&
!transientMembers401
) {
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');
}
async function logout(page: Page) {
await page.goto('/auth/logout');
}
/** Open Spaces and select the seeded shared space; returns the detail locator. */
async function openSharedSpace(page: Page) {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
const row = rail.locator(`[data-testid="space-row"][data-space-id="${sharedSpaceId}"]`);
await expect(row).toBeVisible({ timeout: 15_000 });
await row.click();
const detail = page.getByTestId('space-detail');
await expect(detail).toBeVisible();
return detail;
}
/** Open the seeded chat in the already-open shared space; returns the conversation. */
async function openSeededChat(page: Page) {
const chatRow = page.locator(`[data-testid="space-chat-row"][data-task-id="${chatTaskId}"]`);
await expect(chatRow).toBeVisible({ timeout: 15_000 });
await chatRow.click();
const conv = page.getByTestId('space-conversation');
await expect(conv).toBeVisible();
return conv;
}
/** Create a fresh case space via the UI and select it (for the picker test). */
async function createAndOpenCaseSpace(page: Page, label: string) {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRow).toBeVisible();
await caseRow.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
}
async function openMembersPanel(page: Page) {
await page.getByTestId('space-tab-settings').click();
await page.getByTestId('space-settings-nav-members').click();
const panel = page.getByTestId('space-members-panel');
await expect(panel).toBeVisible({ timeout: 15_000 });
return panel;
}
test.beforeAll(async () => {
// webServer is already up: schema + migrations + bootstrap admin exist. Seed
// the three personas, a local org (manager + member, NOT stranger), the shared
// case space (manager owner, member editor), and a chat task owned by manager
// with a real workspace + logs/ dir — all 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 };
createLocalOrg: (name: string, createdBy: string | null) => { id: string };
addOrgMember: (orgId: string, userId: string, role?: string) => void;
listLocalOrgs: () => Array<{ id: string; name: string }>;
listOrgMembers: (orgId: string) => Array<{ userId: string; role: string }>;
createSpace: (p: {
kind: string; title: string; ownerId: string; visibility?: string;
}) => Promise<{ id: string }>;
addSpaceMember: (p: {
spaceId: string; userId: string; role: string; invitedBy?: string | null;
}) => Promise<void>;
createLocalTask: (p: Record<string, unknown>) => Promise<{ id: number }>;
close?: () => void;
};
};
const repo = new Repository(DB_PATH);
try {
const ensure = (u: typeof MANAGER, role: 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: 'active',
}).id;
};
// manager is a REGULAR user (admins bypass org-scoping in the picker).
managerId = ensure(MANAGER, 'user');
memberId = ensure(MEMBER, 'user');
strangerId = ensure(STRANGER, 'user');
// REAL local-org seeding (idempotent across reruns of the throwaway DB).
const ORG_NAME = 'e2e-sharing-scope-orgA';
const existingOrg = repo.listLocalOrgs().find((o) => o.name === ORG_NAME);
const orgId = existingOrg ? existingOrg.id : repo.createLocalOrg(ORG_NAME, managerId).id;
repo.addOrgMember(orgId, managerId, 'member');
repo.addOrgMember(orgId, memberId, 'member');
// stranger intentionally NOT added to the org.
const orgMemberIds = repo.listOrgMembers(orgId).map((m) => m.userId);
if (!orgMemberIds.includes(managerId) || !orgMemberIds.includes(memberId) || orgMemberIds.includes(strangerId)) {
throw new Error(`org seeding wrong: ${JSON.stringify(orgMemberIds)}`);
}
// Shared case space: manager owner, member editor, stranger absent.
const space = await repo.createSpace({ kind: 'case', title: '案件-共有範囲', ownerId: managerId, visibility: 'private' });
sharedSpaceId = space.id;
await repo.addSpaceMember({ spaceId: sharedSpaceId, userId: memberId, role: 'editor', invitedBy: managerId });
// Chat task owned by manager, inside the space, forced private, with a real
// workspace + logs/ dir (so files?section=logs resolves, not "Workspace not
// found"). Mirrors src/bridge/local-tasks-api.space-view.test.ts.
const ws = join(WORKTREE_DIR, 'space', sharedSpaceId, 'scope-chat-ws');
mkdirSync(join(ws, 'logs'), { recursive: true });
mkdirSync(join(ws, 'input'), { recursive: true });
mkdirSync(join(ws, 'output'), { recursive: true });
writeFileSync(join(ws, 'logs', 'activity.log'), 'seeded log line\n');
const task = await repo.createLocalTask({
title: '共有チャットの本文タイトル',
body: '共有スペースの会話本文です。',
ownerId: managerId,
visibility: 'private',
spaceId: sharedSpaceId,
workspacePath: ws,
});
chatTaskId = task.id;
} finally {
repo.close?.();
}
});
// Sanity: the seed produced usable ids and the chat is owner-readable (baseline
// 200 for the file-scope assertions). Logs in as manager and reads back.
test('seed sanity: manager can read own space chat detail + logs', async ({ page }) => {
expect(sharedSpaceId, 'shared space seeded').toBeTruthy();
expect(chatTaskId, 'chat task seeded').toBeGreaterThan(0);
await login(page, MANAGER.email, MANAGER.password);
const detail = await page.request.get(`/api/local/tasks/${chatTaskId}`);
expect(detail.status(), 'manager reads own chat').toBe(200);
const logs = await page.request.get(`/api/local/tasks/${chatTaskId}/files?section=logs&path=`);
expect(logs.status(), 'manager reads own chat logs').toBe(200);
});
// 1. MEMBER can collaborate (UI + API). The shared space appears in member's
// rail; member opens the manager-owned chat; the chat detail + files/logs
// load. API-level: GET task === 200 and files?section=logs === 200 for member.
test('member sees the shared space, opens manager chat, files/logs load (200)', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, MEMBER.email, MEMBER.password);
await openSharedSpace(page); // space visible + opens (membership grants access, no 403)
// The manager-owned chat row is listed; open it; its content (manager's title)
// is visible to the member.
const conv = await openSeededChat(page);
await expect(conv).toContainText('共有チャット', { timeout: 15_000 });
// Switch to this chat's Files tab → it becomes the selected tab and the
// tabpanel (id, not testid) renders (no 404/error). The per-chat Files tab is
// distinct from the space-level ファイル tab; it shows runs/{taskId} logs.
const filesTab = page.getByTestId('space-chat-tab-files');
await expect(filesTab).toBeVisible({ timeout: 15_000 });
await filesTab.click();
await expect(filesTab).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('#space-chat-tabpanel')).toBeVisible();
// API-level scope proof in member's authed browser context.
const detail = await page.request.get(`/api/local/tasks/${chatTaskId}`);
expect(detail.status(), 'member reads co-member chat detail').toBe(200);
const detailBody = (await detail.json()) as { task?: { title?: string } };
expect(detailBody.task?.title).toContain('共有チャット');
const logs = await page.request.get(`/api/local/tasks/${chatTaskId}/files?section=logs&path=`);
expect(logs.status(), 'member reads co-member chat logs').toBe(200);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 2. STRANGER is blocked. The space is NOT in stranger's rail. API-level:
// GET task === 404 (+ files === 404, + space === 404). The 404s are driven via
// page.request (NOT a navigation), so the fatal tracker never sees the
// expected 404 — it stays scoped to DOM navigation requests.
test('stranger cannot see the shared space or its chat (404)', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, STRANGER.email, STRANGER.password);
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
// The shared space is NOT in stranger's rail.
const sharedRow = rail.locator(`[data-testid="space-row"][data-space-id="${sharedSpaceId}"]`);
await expect(sharedRow).toHaveCount(0);
// API-level scope proof: stranger is denied the chat detail + logs (404), the
// SAME requests the member got 200 on. Out-of-band fetches with stranger's
// cookies → the navigation-scoped fatal tracker does not observe them.
const detail = await page.request.get(`/api/local/tasks/${chatTaskId}`);
expect(detail.status(), 'stranger denied chat detail').toBe(404);
const logs = await page.request.get(`/api/local/tasks/${chatTaskId}/files?section=logs&path=`);
expect(logs.status(), 'stranger denied chat logs').toBe(404);
const space = await page.request.get(`/api/local/spaces/${sharedSpaceId}`);
expect(space.status(), 'stranger denied the space').toBe(404);
expect(fatalErrors, `fatal errors (DOM navigation only):\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 3. PICKER org-scope. In manager's invite picker, `member` (same org) is listed
// and `stranger` (no shared org) is NOT. Also asserts the org-scope note.
test('invite picker lists same-org member, excludes the stranger', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, MANAGER.email, MANAGER.password);
// Fresh space so no one is pre-added (clean candidate set).
await createAndOpenCaseSpace(page, 'E2E組織スコープ');
const panel = await openMembersPanel(page);
await panel.getByTestId('space-member-invite').click();
const picker = panel.getByTestId('space-member-picker');
await expect(picker).toBeVisible({ timeout: 15_000 });
// Org-scope note shown (replaces the no-auth hint).
await expect(picker.getByTestId('space-member-picker-org-note')).toBeVisible();
await expect(picker.getByText('認証を有効化するとワークスペースを共有できます。')).toHaveCount(0);
// Same-org member is a candidate; the stranger (no shared org) is NOT.
await expect(picker.getByRole('button', { name: new RegExp(MEMBER.name) })).toBeVisible({ timeout: 15_000 });
await expect(picker.getByRole('button', { name: new RegExp(STRANGER.name) })).toHaveCount(0);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 4. NO visibility selector. The space chat shows the static visibility NOTE and
// never the removed selector control.
test('space chat shows the static visibility note, not a selector', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, MANAGER.email, MANAGER.password);
await openSharedSpace(page);
await openSeededChat(page);
// The static note is present; the removed selector is absent.
await expect(page.getByTestId('space-chat-visibility-note')).toBeVisible({ timeout: 15_000 });
await expect(page.getByTestId('space-chat-visibility-note')).toContainText('メンバーに公開');
await expect(page.getByTestId('space-chat-visibility')).toHaveCount(0);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
+281
View File
@@ -0,0 +1,281 @@
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 WORKSPACE-INVITE-LINK E2E ──────────────────────────────────────
//
// Proves the space invite-link feature on feat/space-invite-links, end-to-end
// through the real server with LOCAL AUTH on (ui/playwright.auth.config.ts →
// ui/e2e-auth/config.e2e-auth.yaml). The WHOLE POINT of an invite link is that it
// bypasses the org-scoped picker: a user who shares NO org with the owner can
// still join via the token. So two personas suffice:
//
// owner — owns a case space, can manage it (mint/revoke invites). role 'user'.
// joiner — a separate active user in NO shared org with owner. Must NOT see the
// space until they accept the invite, then becomes a member.
//
// IDENTITY ISOLATION: this file uses its own *@invite.local emails so it never
// collides with shared-space.auth.spec.ts / sharing-scope.auth.spec.ts, which
// seed personas into the SAME shared DB. Reusing those (esp. an admin or an
// org-sharing pair) would mask the "no shared org" precondition the invite relies
// on.
//
// SEEDING (real, not fallback): both users + the owner's case space are created in
// beforeAll via the BUILT Repository (compiled dist), into the SAME deterministic
// DB the webServer opened. No org membership is granted to EITHER user, so the
// joiner is provably outside the owner's org-scoped candidate pool. Seeding in
// beforeAll (not a test body) keeps the ids deterministic for every test.
//
// DRIVING STYLE: invite CREATION / REVOCATION runs via authenticated page.request
// from the OWNER's browser context (the API is the contract); the JOIN flow is
// exercised through the REAL UI at /ui/invite/:token (JoinSpace.tsx).
const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// __dirname is <repo>/ui/e2e-auth → repo root is two levels up.
const repoRoot = resolve(__dirname, '..', '..');
// Same deterministic DB path the webServer uses (ui/playwright.auth.config.ts
// builds it identically: <tmpdir>/maestro-e2e-auth/e2e-auth.db). Computing it
// independently keeps the spec decoupled from Playwright's config internals while
// guaranteeing both open the SAME SQLite file.
const DB_PATH = join(tmpdir(), 'maestro-e2e-auth', 'e2e-auth.db');
const OWNER = { email: '[email protected]', password: 'OwnerPass123!', name: 'InviteOwner' };
const JOINER = { email: '[email protected]', password: 'JoinerPass123!', name: 'InviteJoiner' };
// Resolved in beforeAll, reused by every test.
let ownerId = '';
let joinerId = '';
let caseSpaceId = '';
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;
const BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
// The invite preview GET is intentionally 404 for invalid/revoked tokens (tests 3
// & 4 navigate to such a token through the UI → JoinSpace fetches the preview and
// the server returns 404 by design). That 404 is the feature working, not a bug,
// so it must not trip the navigation-scoped fatal tracker.
const INVITE_PREVIEW_RE = /\/api\/local\/spaces\/invite\/[^/]+$/;
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());
const expectedInvitePreview404 = res.status() === 404 && INVITE_PREVIEW_RE.test(pathname);
if (
!BENIGN_ASSET.test(pathname) &&
!BENIGN_PATHS.has(pathname) &&
!BENIGN_PATH_RE.test(pathname) &&
!expectedInvitePreview404
) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
/** Log a real user in via the browser form. Success → redirect chain settles off
* /auth/login onto the SPA at /ui/. */
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');
}
async function logout(page: Page) {
await page.goto('/auth/logout');
}
/** Mint an invite for the seeded case space via the owner's authed page.request.
* Returns the captured token. */
async function createInvite(page: Page, role: 'viewer' | 'editor'): Promise<string> {
const res = await page.request.post(`/api/local/spaces/${caseSpaceId}/invite`, {
data: { role },
});
expect(res.status(), `owner mints invite (role ${role})`).toBe(201);
const body = (await res.json()) as {
invite?: { token?: string; role?: string; valid?: boolean; url?: string };
};
expect(body.invite?.role, 'minted invite has the requested role').toBe(role);
expect(body.invite?.valid, 'minted invite is valid').toBe(true);
const token = body.invite?.token;
expect(token, 'invite token captured').toBeTruthy();
return token as string;
}
test.beforeAll(async () => {
// The webServer is already up (Playwright waits on its url), so schema +
// migrations + bootstrap admin all exist. Seed the two personas (both regular
// 'user's, NEITHER in any org → no shared org) and the owner-owned case space
// 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 };
createSpace: (p: {
kind: string; title: string; ownerId: string; visibility?: string;
}) => Promise<{ id: string }>;
close?: () => void;
};
};
const repo = new Repository(DB_PATH);
try {
const ensure = (u: typeof OWNER, role: 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: 'active',
}).id;
};
// Both are REGULAR users; neither is added to any org → the invite link is the
// only path by which the joiner can reach the owner's private space.
ownerId = ensure(OWNER, 'user');
joinerId = ensure(JOINER, 'user');
// Owner-owned private case space. No members pre-added → the joiner starts
// strictly outside.
const space = await repo.createSpace({
kind: 'case', title: '案件-招待リンク', ownerId, visibility: 'private',
});
caseSpaceId = space.id;
} finally {
repo.close?.();
}
});
// 0. Sanity: the seed produced usable ids and the joiner is genuinely an outsider
// (cannot see the space, is not a member) — the precondition for every test.
test('seed sanity: joiner is an outsider (no space visibility, not a member)', async ({ page }) => {
expect(caseSpaceId, 'case space seeded').toBeTruthy();
expect(joinerId, 'joiner seeded').toBeTruthy();
await login(page, JOINER.email, JOINER.password);
// The owner's private space is NOT in the joiner's spaces list.
const list = await page.request.get('/api/local/spaces');
expect(list.status(), 'joiner lists spaces').toBe(200);
const spaces = (await list.json()) as Array<{ id: string }>;
expect(spaces.some((s) => s.id === caseSpaceId), 'joiner cannot see the space pre-join').toBe(false);
// And the space itself is denied (404 — not enumerable).
const space = await page.request.get(`/api/local/spaces/${caseSpaceId}`);
expect(space.status(), 'joiner denied the space pre-join').toBe(404);
});
// 1+2. Owner mints a viewer invite (via page.request); the joiner (NOT in owner's
// org) opens /ui/invite/<token> in the REAL UI, sees the JoinSpace screen
// with the space title + accept button, joins, lands on the spaces page, and
// is now a member. This is the core "invite bypasses org-scope" proof.
test('joiner opens the invite link and joins the space (org-scope bypassed)', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
// Owner mints the invite.
await login(page, OWNER.email, OWNER.password);
const token = await createInvite(page, 'viewer');
// Switch to the joiner and walk the real invite flow.
await logout(page);
await login(page, JOINER.email, JOINER.password);
await page.goto(`/ui/invite/${token}`);
const joinScreen = page.getByTestId('join-space');
await expect(joinScreen).toBeVisible({ timeout: 15_000 });
// Preview shows the space title + an accept button (the 'ok' branch).
await expect(joinScreen).toContainText('案件-招待リンク', { timeout: 15_000 });
const accept = page.getByTestId('join-space-accept');
await expect(accept).toBeVisible();
// Accept → JoinSpace navigates to /ui/?page=spaces&space=<id>.
await Promise.all([
page.waitForURL(/\/ui\/\?page=spaces&space=/, { timeout: 15_000 }),
accept.click(),
]);
expect(page.url()).toContain(`space=${caseSpaceId}`);
// The joiner is now a member: the members list (in their own authed context)
// includes their userId.
const members = await page.request.get(`/api/local/spaces/${caseSpaceId}/members`);
expect(members.status(), 'joiner reads members after joining').toBe(200);
const roster = (await members.json()) as Array<{ userId: string; role: string }>;
expect(roster.some((m) => m.userId === joinerId), 'joiner appears in the roster').toBe(true);
// And the space is now visible in their spaces list.
const list = await page.request.get('/api/local/spaces');
const spaces = (await list.json()) as Array<{ id: string }>;
expect(spaces.some((s) => s.id === caseSpaceId), 'joiner now sees the space').toBe(true);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 3. An invalid / unknown token shows the invalid state — NOT the accept button.
test('invalid token shows the invalid state, no accept button', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await login(page, JOINER.email, JOINER.password);
await page.goto('/ui/invite/does-not-exist');
// The JoinSpace shell still renders, but in the invalid branch: invalid copy is
// shown and there is no accept button.
const joinScreen = page.getByTestId('join-space');
await expect(joinScreen).toBeVisible({ timeout: 15_000 });
await expect(joinScreen).toContainText('リンクが無効です', { timeout: 15_000 });
await expect(page.getByTestId('join-space-accept')).toHaveCount(0);
// The expected preview 404 is excused by the tracker; nothing else should error.
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
// 4. After the owner REVOKES the invite (page.request DELETE), the SAME token no
// longer previews/accepts: navigating to /ui/invite/<token> shows the invalid
// state. Uses a fresh invite so revocation here can't affect other tests.
test('revoked token can no longer be used to join', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
// Owner mints a fresh invite, then immediately revokes it.
await login(page, OWNER.email, OWNER.password);
const token = await createInvite(page, 'viewer');
// Preview is valid while live (owner context, page.request — out of band).
const before = await page.request.get(`/api/local/spaces/invite/${token}`);
expect(before.status(), 'invite previews while live').toBe(200);
const del = await page.request.delete(`/api/local/spaces/${caseSpaceId}/invite`);
expect(del.status(), 'owner revokes the invite').toBe(204);
// Switch to the joiner: the revoked token now renders the invalid state.
await logout(page);
await login(page, JOINER.email, JOINER.password);
await page.goto(`/ui/invite/${token}`);
const joinScreen = page.getByTestId('join-space');
await expect(joinScreen).toBeVisible({ timeout: 15_000 });
await expect(joinScreen).toContainText('リンクが無効です', { timeout: 15_000 });
await expect(page.getByTestId('join-space-accept')).toHaveCount(0);
// The preview API itself is 404 for the revoked token (proof at the contract).
const after = await page.request.get(`/api/local/spaces/invite/${token}`);
expect(after.status(), 'revoked token previews as 404').toBe(404);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
+139
View File
@@ -0,0 +1,139 @@
import { test, expect, type Page } from '@playwright/test';
// E2E smoke for the V2 cross-space calendar (top-bar "カレンダー" tab).
//
// Drives the real orchestrator with auth off → synthetic 'local' admin, who can
// see every space and edit events. We create a case space, add an event for
// today via the per-space calendar (reusing its tested flow), then open the
// top-bar Calendar tab and assert the cross view shows that space's dot on
// today and groups the day panel by space.
//
// Benign-path allowlist mirrors spaces.spec.ts: with auth disabled the UI's
// /api/auth/me probe 404s on purpose; org/MCP/ssh probes 401/404. All benign.
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;
const BENIGN_PATH_RE =
/\/api\/local\/tasks\/\d+\/console\/status$|\/api\/local\/browser\/sessions\/task-session\/\d+$/;
function trackFatalErrors(page: Page): string[] {
const fatalErrors: string[] = [];
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) && !BENIGN_PATH_RE.test(pathname)) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
/** Viewer-local 'YYYY-MM-DD' today, matching the UI's localToday(). */
function e2eLocalToday(): string {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
async function createAndOpenCaseSpace(page: Page, label: string) {
await page.goto('/ui');
await page.getByTestId('nav-spaces').click();
const rail = page.getByTestId('space-rail');
await expect(rail).toBeVisible();
await page.getByTestId('create-space-btn').click();
const titleInput = page.getByTestId('space-title-input');
await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click();
await expect(titleInput).toHaveCount(0);
const caseRow = rail
.locator('[data-testid="space-row"][data-space-kind="case"]')
.filter({ hasText: caseTitle });
await expect(caseRow).toBeVisible();
const spaceId = await caseRow.getAttribute('data-space-id');
expect(spaceId).toBeTruthy();
await caseRow.click();
await expect(page.getByTestId('space-detail')).toBeVisible();
return { caseTitle, spaceId: spaceId as string };
}
/** Add an event for today in the currently-open space's calendar tab. */
async function addTodayEvent(page: Page, title: string) {
await page.getByTestId('space-tab-calendar').click();
await expect(page.getByTestId('space-cal-grid')).toBeVisible();
const today = e2eLocalToday();
await page.getByTestId(`space-cal-day-${today}`).click();
const panel = page.getByTestId('space-cal-day-panel');
await expect(panel).toBeVisible();
await panel.getByTestId('space-cal-add-event-btn').click();
const form = page.getByTestId('space-cal-add-event');
await expect(form).toBeVisible();
await form.getByTestId('space-cal-event-title').fill(title);
await form.getByTestId('space-cal-event-time').fill('11:00');
await form.getByTestId('space-cal-event-save').click();
await expect(panel.getByText(title, { exact: true })).toBeVisible({ timeout: 10_000 });
}
test('cross calendar: top-bar tab opens, grid renders, month nav works', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
await page.goto('/ui');
await page.getByTestId('nav-calendar').click();
await expect(page.getByTestId('cross-calendar')).toBeVisible();
await expect(page.getByTestId('cross-cal-grid')).toBeVisible();
await expect(page).toHaveURL(/[?&]page=calendar(&|$)/);
// today's cell exists
await expect(page.getByTestId(`cross-cal-day-${e2eLocalToday()}`)).toBeVisible();
// month nav flips the YYYY年M月 header and returns
const title = page.getByTestId('cross-cal-title');
const initial = (await title.textContent())?.trim();
expect(initial).toMatch(/\d{4}年\d{1,2}月/);
await page.getByTestId('cross-cal-next').click();
await expect(title).not.toHaveText(initial!);
await page.getByTestId('cross-cal-prev').click();
await expect(title).toHaveText(initial!);
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
test('cross calendar: a space with activity shows its dot + day panel groups by space', async ({ page }) => {
const fatalErrors = trackFatalErrors(page);
const { caseTitle, spaceId } = await createAndOpenCaseSpace(page, 'E2E横断カレンダー');
const eventTitle = `横断予定-${Date.now()}`;
await addTodayEvent(page, eventTitle);
// Open the top-bar cross calendar.
await page.getByTestId('nav-calendar').click();
await expect(page.getByTestId('cross-cal-grid')).toBeVisible();
const today = e2eLocalToday();
const todayCell = page.getByTestId(`cross-cal-day-${today}`);
// Today's cell carries the activity dot for our space.
await expect(todayCell.getByTestId(`cross-cal-dot-${spaceId}`)).toBeVisible({ timeout: 10_000 });
// Click the day → panel grouped by space, with our space heading + its event.
await todayCell.click();
const panel = page.getByTestId('cross-cal-day-panel');
await expect(panel).toBeVisible();
const spaceGroup = panel.getByTestId(`cross-cal-space-${spaceId}`);
await expect(spaceGroup).toBeVisible();
await expect(spaceGroup.getByText(caseTitle, { exact: true })).toBeVisible();
await expect(spaceGroup.getByText(eventTitle, { exact: true })).toBeVisible({ timeout: 10_000 });
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
});
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
import { test, expect, type Page } from '@playwright/test';
// ── Tasks page sub-tabs (タスク / ファイル / 設定) ─────────────────────────────
//
// 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`).
//
// 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
// the first /api/local/spaces call. Personal-workspace file listing may 404
// when the tree is empty — that is benign here.
const TASKS_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 TASKS_BENIGN_ASSET = /\.(ico|png|svg|map|webmanifest|json)$/i;
// Personal-workspace files endpoints may legitimately 404 with an empty tree
// (the SpaceFiles source-library group probes for source/index.jsonl content).
const TASKS_BENIGN_PATH_RE = /\/api\/local\/spaces\/[^/]+\/files(\/content)?($|\?|\/)/;
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 (
!TASKS_BENIGN_ASSET.test(pathname) &&
!TASKS_BENIGN_PATHS.has(pathname) &&
!TASKS_BENIGN_PATH_RE.test(pathname)
) {
fatalErrors.push(`HTTP ${res.status()} ${res.url()}`);
}
}
});
return fatalErrors;
}
test('tasks page: タスク / ファイル / 設定 sub-tabs switch the content area', 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=/);
expect(fatalErrors).toEqual([]);
});
test('tasks page: tasksTab=files deep-link lands on the files sub-tab', 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();
expect(fatalErrors).toEqual([]);
});
+95
View File
@@ -0,0 +1,95 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
// __dirname under ESM.
const __dirname = dirname(fileURLToPath(import.meta.url));
// Repo root is one level up from ui/.
const repoRoot = resolve(__dirname, '..');
// Separate port from the auth-OFF harness (8788) so both configs can coexist.
const PORT = Number(process.env.E2E_AUTH_PORT ?? 8790);
const BASE_URL = `http://127.0.0.1:${PORT}`;
// Isolated throwaway data + workspace dirs — never touches real ./data/maestro.db.
//
// DETERMINISTIC path (NOT mkdtemp): Playwright imports this config in the main
// runner process AND re-imports it in each spec worker process. A random
// mkdtemp would yield a DIFFERENT DB_PATH per import — the webServer (started
// from the main-process value) and the spec's seeding code would then open
// DIFFERENT databases, so seeded members would never appear. A fixed path makes
// every import agree on one DB. The spec computes the SAME path independently
// (see DB_PATH in the spec) and seeds into it. We do NOT delete it at import
// time (a worker import would nuke the live DB); the server rebuilds its schema
// on boot and seeding is idempotent, so a stale file from a prior run is benign.
const e2eTmp = join(tmpdir(), 'maestro-e2e-auth');
mkdirSync(e2eTmp, { recursive: true });
const DB_PATH = join(e2eTmp, 'e2e-auth.db');
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
// Absolute path to the local-auth config YAML (enables POST /auth/local login).
const AAO_CONFIG = resolve(repoRoot, 'ui/e2e-auth/config.e2e-auth.yaml');
export default defineConfig({
testDir: resolve(__dirname, 'e2e-auth'),
testMatch: '**/*.spec.ts',
// Build+boot can be slow; roomy per-test budget.
timeout: 60_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
use: {
baseURL: BASE_URL,
headless: true,
// NOTE: the wide viewport that keeps the desktop nav (data-testid="nav-*")
// out of the hamburger drawer is set on the chromium project below, AFTER
// the Desktop Chrome device spread (which would otherwise pin 1280×720).
// Under LOCAL AUTH the TopBar carries extra right-side chrome (user chip,
// change-password, logout) that collapses it to compact mode at 1280 — and
// the drawer nav exposes no nav-* testids.
// This sandbox disables the Chromium sandbox (bwrap/userns off). Launch
// with --no-sandbox or every browser launch fails to spawn.
launchOptions: {
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
},
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
// Device spread sets 1280×720; override AFTER it so the wide viewport wins.
use: { ...devices['Desktop Chrome'], viewport: { width: 1600, height: 900 } },
},
],
// Boot the real orchestrator (worker mode) with LOCAL AUTH ON (AAO_CONFIG
// points at the local-auth YAML). The bootstrap admin is seeded at startup;
// member users are seeded by the spec's beforeAll into the same temp DB.
// dist/ must exist (npm run build:all).
webServer: {
command: 'node dist/main.js',
cwd: repoRoot,
url: `${BASE_URL}/ui`,
reuseExistingServer: false,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
PORT: String(PORT),
DB_PATH,
WORKTREE_DIR,
AAO_CONFIG,
AAO_MODE: 'worker',
LOG_LEVEL: 'warn',
MCP_ENCRYPTION_KEY: '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff',
},
},
});
+76
View File
@@ -0,0 +1,76 @@
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 { join } from 'node:path';
// __dirname under ESM.
const __dirname = dirname(fileURLToPath(import.meta.url));
// Repo root is one level up from ui/.
const repoRoot = resolve(__dirname, '..');
// Dedicated E2E port — avoids clashing with a dev server on 9876.
const PORT = Number(process.env.E2E_PORT ?? 8788);
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-'));
const DB_PATH = join(e2eTmp, 'e2e.db');
const WORKTREE_DIR = join(e2eTmp, 'workspaces');
export default defineConfig({
testDir: resolve(__dirname, 'e2e'),
// The build+boot can be slow; keep a roomy per-test budget.
timeout: 60_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [['list']],
use: {
baseURL: BASE_URL,
headless: true,
// This sandbox disables the Chromium sandbox (bwrap/userns off). Launch
// with --no-sandbox or every browser launch fails to spawn.
launchOptions: {
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
},
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// Boot the real orchestrator (worker mode) with auth OFF (no config.yaml =>
// defaults => synthetic 'local' user). dist/ must exist (npm run build:all).
webServer: {
command: 'node dist/main.js',
cwd: repoRoot,
url: `${BASE_URL}/ui`,
reuseExistingServer: false,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
PORT: String(PORT),
DB_PATH,
WORKTREE_DIR,
AAO_MODE: 'worker',
LOG_LEVEL: 'warn',
// Mount the MCP subsystem (gated on a 64-hex MCP_ENCRYPTION_KEY) so the
// per-space MCP isolation E2E can register/list servers via /api/mcp/*.
// A fixed throwaway key keeps the run deterministic; the temp DB it
// encrypts is discarded after the run.
MCP_ENCRYPTION_KEY: '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff',
},
},
});
+121 -4
View File
@@ -17,7 +17,7 @@ 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 } from './lib/urlState';
import { COLUMN_LIST, MOBILE_TAB_LIST, type MobileTabId, type PageId, type TasksTabId } from './lib/urlState';
import { confirmDiscardUnsaved } from './lib/unsavedGuard';
import { useBackdropClose } from './lib/useBackdropClose';
import { TopBar } from './components/layout/TopBar';
@@ -42,9 +42,14 @@ import { SchedulesPage } from './pages/SchedulesPage';
import { UsersPage } from './pages/UsersPage';
import { AdminCaptchaPage } from './pages/AdminCaptchaPage';
import { SharedView } from './pages/SharedView';
import { UserFolderTab } from './components/userfolder/UserFolderTab';
import { JoinSpace } from './components/spaces/JoinSpace';
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';
@@ -96,6 +101,13 @@ export function App() {
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])} />;
}
return <AuthenticatedApp />;
}
@@ -181,6 +193,23 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// 認証なしで users ページにアクセスした場合は tasks にフォールバック
const page = (urlState.page === 'users' && !authEnabled) ? 'tasks' : 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 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',
@@ -425,6 +454,25 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
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,
@@ -450,6 +498,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
onPreview: handleLocalFilePreview,
onRefresh: fileBrowser.refresh,
isRefreshing: fileBrowser.isRefreshing,
fileManagement,
onViewFullLog: () => handleLocalFilePreview('activity.log', 'activity.log'),
subtaskActivities,
onSubtaskFilePreview: handleSubtaskFilePreview,
@@ -524,15 +573,82 @@ 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 === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
{page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>}
{page === 'usage' && <div className="flex-1 min-h-0 overflow-hidden flex flex-col"><UsagePage /></div>}
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
{page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden">
{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 ? (
@@ -699,6 +815,7 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div>
)}
</div>
</div>
</div>
{/* Tablet: detail overlay */}
+619 -100
View File
@@ -119,6 +119,8 @@ export interface LocalTask {
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
visibilityScopeOrgName?: string | null;
/** Spaces foundation: which space this task belongs to (null = legacy/個人). */
spaceId?: string | null;
createdAt: string;
updatedAt: string;
latestJob?: {
@@ -209,6 +211,13 @@ export interface CreateLocalTaskInput {
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
browserSessionProfileId?: number | null;
/**
* Spaces foundation: 'persistent'(既定)はスペースのワークスペースに蓄積、
* 'ephemeral' は使い捨て。未指定時はバックエンドが 'persistent' に解決する。
*/
workspaceMode?: 'persistent' | 'ephemeral';
/** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */
spaceId?: string;
options?: {
mcpDisabled?: boolean;
skillsDisabled?: boolean;
@@ -233,6 +242,219 @@ export async function createLocalTask(input: CreateLocalTaskInput): Promise<{ ta
return data;
}
// --- Spaces (個人/案件) ---------------------------------------------------
export interface Space {
id: string;
kind: 'personal' | 'case';
title: string;
description: string;
ownerId: string | null;
visibility: 'private' | 'org' | 'public';
visibilityScopeOrgId: string | null;
status: 'open' | 'archived';
brandColor: string | null;
workspaceDir: string | null;
createdAt: string;
updatedAt: string;
/** 閲覧者自身のメンバーロール。一覧(GET /spaces)・詳細(GET /spaces/:id)の両方で付与。
* 非メンバー(根オーナー含む。owner_id はメンバー行ではない)は null。 */
myRole?: SpaceMemberRole | null;
}
export async function fetchSpaces(): Promise<Space[]> {
const res = await fetch(`${BASE}/local/spaces`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch spaces');
return data as Space[];
}
export type SpaceMemberRole = 'owner' | 'editor' | 'viewer';
export interface SpaceMember {
userId: string;
name: string | null;
email: string | null;
avatarUrl: string | null;
role: SpaceMemberRole;
isOwner: boolean;
}
export async function fetchSpaceMembers(spaceId: string): Promise<SpaceMember[]> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch members');
return data as SpaceMember[];
}
export async function addSpaceMember(
spaceId: string,
input: { userId: string; role: SpaceMemberRole },
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to add member');
}
}
export async function updateSpaceMemberRole(
spaceId: string,
userId: string,
role: SpaceMemberRole,
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to update member role');
}
}
export async function removeSpaceMember(spaceId: string, userId: string): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/members/${encodeURIComponent(userId)}`, {
method: 'DELETE',
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to remove member');
}
}
// ─── ワークスペース招待リンク(再利用トークン)──────────────────────
export type SpaceInviteRole = 'editor' | 'viewer';
export interface SpaceInviteInfo {
token: string;
/** 相対パス。origin を前置して共有する。 */
url: string;
role: SpaceInviteRole;
createdAt: string;
expiresAt: string | null;
revokedAt: string | null;
valid: boolean;
}
/** 現行の招待リンク(canManageSpace)。無ければ null。 */
export async function fetchSpaceInvite(spaceId: string): Promise<SpaceInviteInfo | null> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch invite');
return (data?.invite ?? null) as SpaceInviteInfo | null;
}
/** 招待リンクを (再)生成する(canManageSpace)。 */
export async function createSpaceInvite(
spaceId: string,
input: { role: SpaceInviteRole; expiresInDays?: number | null },
): Promise<SpaceInviteInfo> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to create invite');
return data.invite as SpaceInviteInfo;
}
/** 招待リンクを無効化する(canManageSpace)。 */
export async function revokeSpaceInvite(spaceId: string): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/invite`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to revoke invite');
}
}
export interface InvitePreview {
spaceId: string;
spaceTitle: string;
role: SpaceInviteRole;
}
/**
* 招待トークンのプレビュー。`status` で分岐を呼び出し側に渡す:
* 'ok' → preview / 'unauthorized'(要ログイン)/ 'invalid'(無効・期限切れ・不明)。
*/
export async function fetchInvitePreview(
token: string,
): Promise<{ status: 'ok'; preview: InvitePreview } | { status: 'unauthorized' | 'invalid' }> {
const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}`);
if (res.status === 401) return { status: 'unauthorized' };
if (!res.ok) return { status: 'invalid' };
const data = await res.json();
return { status: 'ok', preview: data as InvitePreview };
}
/** 招待を受諾して参加する。参加先 spaceId を返す。 */
export async function acceptSpaceInvite(token: string): Promise<{ spaceId: string; alreadyMember: boolean }> {
const res = await fetch(`${BASE}/local/spaces/invite/${encodeURIComponent(token)}/accept`, {
method: 'POST',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to accept invite');
return data as { spaceId: string; alreadyMember: boolean };
}
export interface PickableUser {
id: string;
name: string | null;
email: string | null;
avatarUrl: string | null;
}
export async function fetchPickableUsers(): Promise<PickableUser[]> {
const res = await fetch(`${BASE}/users/pickable`);
if (!res.ok) return [];
return (await res.json()) as PickableUser[];
}
export async function createSpace(input: {
title: string;
description?: string;
brandColor?: string | null;
visibility?: Space['visibility'];
}): Promise<Space> {
const res = await fetch(`${BASE}/local/spaces`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create space');
return data as Space;
}
export async function updateSpace(
id: string,
patch: Partial<Pick<Space, 'title' | 'description' | 'brandColor' | 'visibility'>>,
): Promise<Space> {
const res = await fetch(`${BASE}/local/spaces/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update space');
return data as Space;
}
export async function archiveSpace(id: string): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${id}/archive`, { method: 'POST' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to archive space');
}
}
export interface PromptCoachAxis {
name: string;
score: number;
@@ -367,6 +589,320 @@ export function getTrustedLocalHtmlUrl(taskId: number, section: 'workspace' | 'i
return `${BASE}/local/tasks/${taskId}/files/raw?${params.toString()}`;
}
// アップロード・削除が許される区分。サーバ側 WRITABLE_SECTIONS と一致させること。
export type WritableTaskSection = 'input' | 'output';
// タスクワークスペースの input/output へファイルをアップロード(複数可)。サーバが
// O_EXCL で衝突回避リネーム + ensurePathWithin で section root に封じ込め。実行中は 409。
export async function uploadLocalFiles(
taskId: number,
section: WritableTaskSection,
path: string,
files: { name: string; contentBase64: string }[],
): Promise<{ uploaded: { name: string; path: string }[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, path, files }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files');
return data as { uploaded: { name: string; path: string }[] };
}
// タスクワークスペースの input/output ファイルを削除(複数選択は paths 配列)。サーバ側で
// section root に封じ込め、ファイルのみ対象、存在しないものは冪等スキップ。
export async function deleteLocalFiles(
taskId: number,
section: WritableTaskSection,
paths: string[],
): Promise<{ deleted: string[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, paths }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files');
return data as { deleted: string[]; skipped: string[] };
}
// Blob を受け取りブラウザのダウンロードを発火する(zip 一括ダウンロード用)。
function triggerBlobDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// タスクワークスペースの複数ファイルを zip でまとめてダウンロード(全 section 可、read)。
// サーバが paths を section root に封じ込め、ファイルのみ zip 化。1 件でも zip にする。
export async function downloadLocalFilesZip(
taskId: number,
section: 'workspace' | 'input' | 'output' | 'logs',
paths: string[],
filename = 'files.zip',
): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/download-zip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section, paths }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to download files');
}
triggerBlobDownload(await res.blob(), filename);
}
// --- Space files (永続ワークスペース {worktreeDir}/space/{id}/files) ---
// タスク版の files APIgetLocalFileRawUrl 等)と同形だが、スペース id でキーする。
export async function fetchSpaceFiles(spaceId: string, path: string = ''): Promise<{ basePath: string; path: string; entries: LocalFileEntry[] }> {
const params = new URLSearchParams();
if (path) params.set('path', path);
const qs = params.toString();
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files${qs ? `?${qs}` : ''}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to list files');
return data;
}
export async function fetchSpaceFileContent(spaceId: string, path: string): Promise<string> {
const params = new URLSearchParams({ path });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/content?${params.toString()}`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to read file');
}
return await res.text();
}
export function getSpaceFileRawUrl(spaceId: string, path: string): string {
const params = new URLSearchParams({ path });
return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`;
}
export function getSpaceTrustedHtmlUrl(spaceId: string, path: string): string {
const params = new URLSearchParams({ path, trusted: '1' });
return `${BASE}/local/spaces/${spaceId}/files/raw?${params.toString()}`;
}
export async function uploadSpaceFiles(
spaceId: string,
path: string,
files: { name: string; contentBase64: string }[],
): Promise<{ uploaded: { name: string; path: string }[] }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, files }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to upload files');
return data as { uploaded: { name: string; path: string }[] };
}
// スペースのワークスペースファイルを削除する。複数選択は paths(相対パス配列)で渡す。
// 各パスはサーバ側で spaceFilesDir に封じ込め(traversal は 400)、ファイルのみ対象。
// 存在しないパスは冪等にスキップされる。
export async function deleteSpaceFiles(
spaceId: string,
paths: string[],
): Promise<{ deleted: string[]; skipped: string[] }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to delete files');
return data as { deleted: string[]; skipped: string[] };
}
// スペースの複数ファイルを zip でまとめてダウンロード(read)。サーバが paths を
// spaceFilesDir に封じ込め、ファイルのみ zip 化。1 件でも zip にする。
export async function downloadSpaceFilesZip(
spaceId: string,
paths: string[],
filename = 'files.zip',
): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/download-zip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error ?? 'Failed to download files');
}
triggerBlobDownload(await res.blob(), filename);
}
// スペースのワークスペースファイルを path 指定で 1 件書き込む(上書き許可)。
// contentUTF-8 テキスト)または contentBase64(任意バイナリ)のどちらかを渡す。
// サーバ側で canEditInSpace + ensurePathWithin によりゲートされる。ワークスペース・
// アプリの postMessage ブリッジ writeFile の代理に使う。
export async function writeSpaceFile(
spaceId: string,
path: string,
body: { content: string } | { contentBase64: string },
): Promise<{ path: string; bytes: number }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/files/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, ...body }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to write file');
return data as { path: string; bytes: number };
}
// --- Space calendar (予定 + 日次集計) ---
// spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md
export interface CalendarEvent {
id: number;
spaceId: string;
ownerId: string | null;
date: string; // 開始日 YYYY-MM-DD(ローカル日付)
endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日
time: string | null; // HH:MM。null = 終日
title: string;
description: string | null;
createdBy: 'user' | 'agent';
sourceTaskId: number | null;
createdAt: string;
updatedAt?: string;
}
export interface CalendarDayCounts {
taskCount: number;
eventCount: number;
}
export interface CalendarMonth {
days: Record<string, CalendarDayCounts>;
events: CalendarEvent[];
}
export interface CalendarDayTask {
id: number;
title: string;
state: string;
status: string | null;
createdAt: string;
}
export interface CalendarDayFile {
name: string;
path: string;
size: number;
mtime: string;
}
export interface CalendarDay {
tasks: CalendarDayTask[];
files: CalendarDayFile[];
events: CalendarEvent[];
}
// ── V2: cross-space calendar ──────────────────────────────────────────────
export interface CrossCalendarSpace {
id: string;
name: string;
color: string; // brand_color、無ければ spaceColor(id) で導出済み
}
export interface CrossCalendarMonth {
// days[date][spaceId] = そのスペースのその日の集計
days: Record<string, Record<string, CalendarDayCounts>>;
events: CalendarEvent[]; // 各イベントは spaceId を持つ
spaces: CrossCalendarSpace[];
}
export async function fetchCrossSpaceCalendarMonth(
month: string,
tzOffset: number,
): Promise<CrossCalendarMonth> {
const params = new URLSearchParams({ month, tz_offset: String(tzOffset) });
const res = await fetch(`${BASE}/calendar/cross?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar');
return data as CrossCalendarMonth;
}
export async function fetchSpaceCalendarMonth(
spaceId: string,
month: string,
tzOffset: number,
): Promise<CalendarMonth> {
const params = new URLSearchParams({ month, tz_offset: String(tzOffset) });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar');
return data as CalendarMonth;
}
export async function fetchSpaceCalendarDay(
spaceId: string,
date: string,
tzOffset: number,
): Promise<CalendarDay> {
const params = new URLSearchParams({ date, tz_offset: String(tzOffset) });
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/day?${params.toString()}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch calendar day');
return data as CalendarDay;
}
export async function createCalendarEvent(
spaceId: string,
input: { date: string; endDate?: string | null; time?: string | null; title: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, ...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 }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create event');
return data as CalendarEvent;
}
export async function updateCalendarEvent(
spaceId: string,
eventId: number,
patch: { date?: string; endDate?: string | null; time?: string | null; title?: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, ...rest } = patch;
const body: Record<string, unknown> = { ...rest };
if (endDate !== undefined) body.end_date = endDate;
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to update event');
return data as CalendarEvent;
}
export async function deleteCalendarEvent(spaceId: string, eventId: number): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
method: 'DELETE',
});
if (!res.ok && res.status !== 204) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
}
export async function updateLocalFileContent(taskId: number, section: string, path: string, content: string): Promise<void> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/files/content`, {
method: 'PUT',
@@ -411,46 +947,58 @@ export interface PieceDef { name: string; description: string; max_movements: nu
/** Full response from GET /api/pieces/:name — includes the server-resolved source. */
export interface PieceFetchResult { piece: PieceDef; source: 'builtin' | 'user-custom' | 'global-custom'; ownerId?: string }
export async function fetchPieces(): Promise<PieceSummary[]> {
const res = await fetch(`${BASE}/pieces`);
/**
* Build a `?key=value&...` query string from defined entries (undefined dropped).
* Used by the space-scoped variants below so passing `spaceId` adds `?spaceId=…`
* and omitting it leaves the per-user URL unchanged (backward compatible).
*/
function buildQuery(params: Record<string, string | undefined>): string {
const parts = Object.entries(params)
.filter(([, v]) => v !== undefined && v !== '')
.map(([k, v]) => `${k}=${encodeURIComponent(v as string)}`);
return parts.length ? `?${parts.join('&')}` : '';
}
export async function fetchPieces(spaceId?: string): Promise<PieceSummary[]> {
const res = await fetch(`${BASE}/pieces${buildQuery({ spaceId })}`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch pieces');
return data.pieces;
}
export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<PieceFetchResult> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
export async function fetchPiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise<PieceFetchResult> {
const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch piece');
return { piece: data.piece, source: data.source, ownerId: data.ownerId };
}
export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
export async function updatePiece(name: string, piece: PieceDef, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise<void> {
const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`;
const res = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(piece),
body: JSON.stringify(spaceId ? { ...piece, spaceId } : piece),
});
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to update piece'); }
}
export interface PieceCreateResult { source: 'builtin' | 'user-custom' | 'global-custom' }
export async function createPiece(piece: PieceDef): Promise<PieceCreateResult> {
const res = await fetch(`${BASE}/pieces`, {
export async function createPiece(piece: PieceDef, spaceId?: string): Promise<PieceCreateResult> {
const res = await fetch(`${BASE}/pieces${buildQuery({ spaceId })}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(piece),
body: JSON.stringify(spaceId ? { ...piece, spaceId } : piece),
});
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to create piece'); }
const d = await res.json();
return { source: d.source ?? 'user-custom' };
}
export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom'): Promise<void> {
const url = source ? `${BASE}/pieces/${name}?source=${source}` : `${BASE}/pieces/${name}`;
export async function deletePiece(name: string, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string): Promise<void> {
const url = `${BASE}/pieces/${name}${buildQuery({ source, spaceId })}`;
const res = await fetch(url, { method: 'DELETE' });
if (!res.ok) { const d = await res.json(); throw new Error(d?.error ?? 'Failed to delete piece'); }
}
@@ -623,23 +1171,31 @@ export interface BrowserSessionProfile {
lastError: string | null;
createdAt: string;
updatedAt: string;
/** Owning space (null = legacy/personal). Surfaced for space-scoped listings. */
spaceId?: string | null;
/** The user who created the profile (its DEK owner). */
ownerId?: string;
/** False when the requesting viewer is NOT the owner: visible but unusable
* (the per-user DEK can only be decrypted by its owner). */
decryptableByViewer?: boolean;
}
const SESS_BASE = `${BASE}/browser-sessions`;
export async function listBrowserSessionProfiles(): Promise<BrowserSessionProfile[]> {
const r = await fetch(`${SESS_BASE}/profiles`, { credentials: 'same-origin' });
export async function listBrowserSessionProfiles(spaceId?: string): Promise<BrowserSessionProfile[]> {
const r = await fetch(`${SESS_BASE}/profiles${buildQuery({ spaceId })}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`listBrowserSessionProfiles: ${r.status}`);
return (await r.json() as { profiles: BrowserSessionProfile[] }).profiles;
}
export async function createBrowserSessionProfile(
input: Partial<BrowserSessionProfile> & { label: string; startUrl: string },
spaceId?: string,
): Promise<BrowserSessionProfile> {
const r = await fetch(`${SESS_BASE}/profiles`, {
const r = await fetch(`${SESS_BASE}/profiles${buildQuery({ spaceId })}`, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
body: JSON.stringify(spaceId ? { ...input, spaceId } : input),
});
if (!r.ok) throw new Error(`createBrowserSessionProfile: ${r.status}`);
return (await r.json() as { profile: BrowserSessionProfile }).profile;
@@ -679,11 +1235,39 @@ export async function testBrowserSessionProfile(id: number): Promise<{
return r.json();
}
export async function deleteBrowserSessionProfile(id: number): Promise<void> {
const r = await fetch(`${SESS_BASE}/profiles/${id}`, { method: 'DELETE', credentials: 'same-origin' });
export async function deleteBrowserSessionProfile(id: number, spaceId?: string): Promise<void> {
const r = await fetch(`${SESS_BASE}/profiles/${id}${buildQuery({ spaceId })}`, { method: 'DELETE', credentials: 'same-origin' });
if (!r.ok) throw new Error(`deleteBrowserSessionProfile: ${r.status}`);
}
// --- User/space folder files (browser-macros / recordings) ---
export interface FolderFileEntry { name: string; size: number; mtime: string }
/**
* List files in a writable user-folder subdir. Passing `spaceId` scopes the
* listing to that space's folder (`data/spaces/{id}/{subdir}`); omitting it
* targets the per-user folder. Matches the convention used by the piece/agents
* space-scoped fetchers above.
*/
export async function listFolderFiles(subdir: 'browser-macros' | 'recordings', spaceId?: string): Promise<FolderFileEntry[]> {
const r = await fetch(`${BASE}/users/me/folder/list${buildQuery({ subdir, spaceId })}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`listFolderFiles: ${r.status}`);
return (await r.json() as { files: FolderFileEntry[] }).files;
}
/** Read one folder file's text content (space-scoped when `spaceId` is given). */
export async function getFolderFile(subdir: 'browser-macros' | 'recordings', path: string, spaceId?: string): Promise<string> {
const r = await fetch(`${BASE}/users/me/folder/file${buildQuery({ subdir, path, spaceId })}`, { credentials: 'same-origin' });
if (!r.ok) throw new Error(`getFolderFile: ${r.status}`);
return r.text();
}
/** Delete one folder file (space-scoped when `spaceId` is given). */
export async function deleteFolderFile(subdir: 'browser-macros' | 'recordings', path: string, spaceId?: string): Promise<void> {
const r = await fetch(`${BASE}/users/me/folder/file${buildQuery({ subdir, path, spaceId })}`, { method: 'DELETE', credentials: 'same-origin' });
if (!r.ok) throw new Error(`deleteFolderFile: ${r.status}`);
}
// --- Reflection ---
export interface LatestReflectionForTask {
snapshotId: string;
@@ -841,20 +1425,6 @@ export async function updatePetSettings(patch: Partial<PetSettings>): Promise<Pe
// ── Side Info Panel ────────────────────────────────────────────────────────
export type DashboardWidgetKind = 'markdown' | 'node-status';
export interface DashboardWidget {
id: number;
userId: string;
slug: string;
title: string;
kind: DashboardWidgetKind;
markdownContent: string;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface NodeStatus {
nodeId: string;
workerId: string;
@@ -892,55 +1462,6 @@ export interface WorkerStatusRow {
backends?: WorkerStatusBackendRow[];
}
export async function fetchDashboardWidgets(): Promise<DashboardWidget[]> {
const res = await fetch('/api/local/dashboard/widgets');
if (!res.ok) throw new Error(`Failed to list dashboard widgets: ${res.status}`);
const body = await res.json();
return body.widgets;
}
export async function createDashboardWidget(input: {
slug: string;
title: string;
content?: string;
kind?: DashboardWidgetKind;
}): Promise<DashboardWidget> {
const res = await fetch('/api/local/dashboard/widgets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`Failed to create widget: ${res.status} ${await res.text()}`);
return (await res.json()).widget;
}
export async function updateDashboardWidget(id: number, patch: {
title?: string;
content?: string;
}): Promise<DashboardWidget> {
const res = await fetch(`/api/local/dashboard/widgets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`Failed to update widget: ${res.status} ${await res.text()}`);
return (await res.json()).widget;
}
export async function deleteDashboardWidget(id: number): Promise<void> {
const res = await fetch(`/api/local/dashboard/widgets/${id}`, { method: 'DELETE' });
if (!res.ok && res.status !== 204) throw new Error(`Failed to delete widget: ${res.status}`);
}
export async function reorderDashboardWidgets(ids: number[]): Promise<void> {
const res = await fetch('/api/local/dashboard/widgets/reorder', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error(`Failed to reorder widgets: ${res.status}`);
}
export async function fetchWorkerStatuses(): Promise<WorkerStatusRow[]> {
const res = await fetch('/api/local/dashboard/workers');
if (!res.ok) throw new Error(`Failed to list worker statuses: ${res.status}`);
@@ -1151,26 +1672,24 @@ export interface SkillDetail extends SkillSummary {
maxSeverity: 'high' | 'medium' | 'none';
}
export async function fetchSkills(scope?: string): Promise<SkillSummary[]> {
const params = scope ? `?scope=${scope}` : '';
const res = await fetch(`/api/skills${params}`);
export async function fetchSkills(scope?: string, spaceId?: string): Promise<SkillSummary[]> {
const res = await fetch(`/api/skills${buildQuery({ scope, spaceId })}`);
if (!res.ok) throw new Error('Failed to fetch skills');
const data = await res.json();
return data.skills;
}
export async function fetchSkillDetail(name: string, scope?: string): Promise<SkillDetail> {
const params = scope ? `?scope=${scope}` : '';
const res = await fetch(`/api/skills/${encodeURIComponent(name)}${params}`);
export async function fetchSkillDetail(name: string, scope?: string, spaceId?: string): Promise<SkillDetail> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`);
if (!res.ok) throw new Error('Skill not found');
return res.json();
}
export async function createSkill(name: string, content: string, scope: string): Promise<{ name: string; severity: string; findings: any[] }> {
const res = await fetch('/api/skills', {
export async function createSkill(name: string, content: string, scope: string, spaceId?: string): Promise<{ name: string; severity: string; findings: any[] }> {
const res = await fetch(`/api/skills${buildQuery({ spaceId })}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, content, scope }),
body: JSON.stringify(spaceId ? { name, content, scope, spaceId } : { name, content, scope }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
@@ -1179,11 +1698,11 @@ export async function createSkill(name: string, content: string, scope: string):
return res.json();
}
export async function updateSkill(name: string, content: string, scope: string): Promise<any> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}?scope=${scope}`, {
export async function updateSkill(name: string, content: string, scope: string, spaceId?: string): Promise<any> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
body: JSON.stringify(spaceId ? { content, spaceId } : { content }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
@@ -1192,19 +1711,19 @@ export async function updateSkill(name: string, content: string, scope: string):
return res.json();
}
export async function deleteSkill(name: string, scope: string): Promise<void> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}?scope=${scope}`, { method: 'DELETE' });
export async function deleteSkill(name: string, scope: string, spaceId?: string): Promise<void> {
const res = await fetch(`/api/skills/${encodeURIComponent(name)}${buildQuery({ scope, spaceId })}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error);
}
}
export async function installSkillFromUrl(url: string, scope: string, selectedSkills?: string[]): Promise<any> {
const res = await fetch('/api/skills/install-from-url', {
export async function installSkillFromUrl(url: string, scope: string, selectedSkills?: string[], spaceId?: string): Promise<any> {
const res = await fetch(`/api/skills/install-from-url${buildQuery({ spaceId })}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, scope, selectedSkills }),
body: JSON.stringify(spaceId ? { url, scope, selectedSkills, spaceId } : { url, scope, selectedSkills }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
@@ -1341,7 +1860,7 @@ export interface UsageByUser extends UsageCounters {
export interface UsageDailyResponse {
from: string;
to: string;
granularity: 'day' | 'week' | 'month';
granularity: 'hour' | 'day' | 'week' | 'month';
groupBy: UsageGroupBy;
tzOffset: number;
scope: 'all' | 'self';
@@ -1357,7 +1876,7 @@ export interface UsageDailyResponse {
export async function getUsageDaily(params: {
from?: string;
to?: string;
granularity?: 'day' | 'week' | 'month';
granularity?: 'hour' | 'day' | 'week' | 'month';
groupBy?: UsageGroupBy;
tzOffset?: number;
}): Promise<UsageDailyResponse> {
@@ -70,9 +70,11 @@ export function SaveRecordingButton({ taskId, className }: Props) {
}
}
function handleUserFolderClick() {
function handleOpenWorkspaceClick() {
// Recordings are stored per-space; they are managed under the
// workspace's 設定 → ブラウザ panel. Send the user to Spaces.
const url = new URL(window.location.href);
url.searchParams.set('page', 'userfolder');
url.searchParams.set('page', 'spaces');
window.location.search = url.searchParams.toString();
}
@@ -109,10 +111,10 @@ export function SaveRecordingButton({ taskId, className }: Props) {
{linkVisible && status.kind === 'success' && (
<button
type="button"
onClick={handleUserFolderClick}
onClick={handleOpenWorkspaceClick}
className="text-[10px] text-accent hover:underline pl-0.5"
>
{t('saveRecording.openInUserFolder')}
{t('saveRecording.openInWorkspace')}
</button>
)}
</span>
+72 -2
View File
@@ -7,6 +7,8 @@ import { AttachmentDropzone } from './AttachmentDropzone';
import { PromptCoachPanel } from './PromptCoachPanel';
import { ScheduleFields } from './ScheduleFields';
import { usePieceList } from '../../hooks/usePieces';
import { useSpaces } from '../../hooks/useSpaces';
import { sortSpacesForRail } from '../../lib/spaceSort';
import { resolvePieceOptions } from '../../lib/splitPieces';
import { useAuthState } from '../../App';
@@ -21,15 +23,24 @@ interface CreateTaskDialogProps {
initialPiece?: string;
initialBody?: string;
placeholder?: string;
/**
* Spaces foundation: スペース詳細から開いた場合に紐付けるスペース。
* 指定時はそのスペースに固定(ピッカー非表示・名称のみ表示)。
* 未指定(グローバルのタスク一覧から開いた)時は個人スペースが既定。
*/
initialSpaceId?: string;
}
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder }: CreateTaskDialogProps) {
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder, initialSpaceId }: CreateTaskDialogProps) {
const { t } = useTranslation('create');
const { data: pieces } = usePieceList();
const { data: spacesData } = useSpaces();
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs, staleTime: 5 * 60 * 1000 });
const { data: sessionProfiles = [] } = useQuery({
queryKey: ['browser-session-profiles'],
queryFn: listBrowserSessionProfiles,
// ラップ必須: react-query は queryFn に QueryFunctionContext を渡すため、直接渡すと
// spaceId 引数にオブジェクトが入り ?spaceId=[object Object] になる(WS2 回帰)。
queryFn: () => listBrowserSessionProfiles(),
staleTime: 60 * 1000,
});
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
@@ -65,7 +76,12 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
outputFormat: 'markdown',
askPolicy: 'low',
priority: 'medium',
workspaceMode: 'persistent',
});
// スペース起点で開いた場合は固定。それ以外はユーザーが任意で選択(既定=個人)。
const [selectedSpaceId, setSelectedSpaceId] = useState<string | undefined>(initialSpaceId);
const sortedSpaces = sortSpacesForRail(spacesData ?? []).filter(s => s.status === 'open');
const fixedSpace = initialSpaceId ? sortedSpaces.find(s => s.id === initialSpaceId) : undefined;
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
const [mcpDisabled, setMcpDisabled] = useState(false);
@@ -131,6 +147,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
visibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
browserSessionProfileId: browserSessionProfileId ?? undefined,
// 固定スペースを最優先、無ければ選択値。未指定なら個人スペースに解決される。
spaceId: initialSpaceId ?? selectedSpaceId ?? undefined,
...(Object.keys(options).length > 0 ? { options } : {}),
};
await onSubmit(submitForm, attachments);
@@ -180,6 +198,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
<label className="block text-[13px] text-slate-600 mb-1.5">{t('body.label')}</label>
<textarea
autoFocus
data-testid="create-task-body"
value={form.body}
onChange={e => setForm(prev => ({ ...prev, body: e.target.value }))}
onKeyDown={e => {
@@ -194,6 +213,56 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
/>
</div>
{/* 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>
<div className="inline-flex rounded-lg border border-slate-200 p-0.5">
{([['persistent', '永続(既定)'], ['ephemeral', '一時的']] as const).map(([mode, label]) => {
const active = (form.workspaceMode ?? 'persistent') === mode;
return (
<button
key={mode}
type="button"
onClick={() => setForm(prev => ({ ...prev, workspaceMode: mode }))}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-colors ${
active ? 'bg-accent text-accent-fg' : 'text-slate-600 hover:bg-slate-50'
}`}
>
{label}
</button>
);
})}
</div>
<p className="text-2xs text-slate-400 mt-1">
==使
</p>
</div>
{/* スペース: 固定 or ピッカー(既定=個人) */}
<div className="min-w-0 flex-1">
<label className="block text-2xs text-slate-500 mb-1"></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 ?? 'このワークスペース'}
</div>
) : (
<select
value={selectedSpaceId ?? ''}
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>
{sortedSpaces
.filter(s => s.kind === 'case')
.map(s => (
<option key={s.id} value={s.id}>{s.title}</option>
))}
</select>
)}
</div>
</div>
{/* Attachments */}
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
@@ -416,6 +485,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
</Dialog.Close>
<button
disabled={submitting}
data-testid="create-task-submit"
onClick={() => void handleSubmit()}
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
>
@@ -1,117 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { DashboardWidgetKind } from '../../api';
import { useBackdropClose } from '../../lib/useBackdropClose';
interface Props {
open: boolean;
existingSlugs: string[];
onClose: () => void;
onCreate: (input: { slug: string; title: string; kind: DashboardWidgetKind }) => Promise<void>;
}
function slugify(title: string, existing: string[]): string {
const base = title
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 32) || 'widget';
if (!existing.includes(base)) return base;
for (let i = 2; i < 100; i++) {
const candidate = `${base}-${i}`.slice(0, 32);
if (!existing.includes(candidate)) return candidate;
}
return `${base}-${Date.now()}`.slice(0, 32);
}
export function AddWidgetDialog({ open, existingSlugs, onClose, onCreate }: Props) {
const { t } = useTranslation('dashboard');
const [title, setTitle] = useState('');
const [kind, setKind] = useState<DashboardWidgetKind>('markdown');
const [saving, setSaving] = useState(false);
const backdrop = useBackdropClose(() => { if (!saving) onClose(); });
if (!open) return null;
// Default titles per kind so the user can pick a kind and get a sensible
// title for free. Either field can be overridden before submit.
const kindDefaultTitle: Record<DashboardWidgetKind, string> = {
'markdown': '',
'node-status': t('kindTitles.nodeStatus'),
};
// Effective title: explicit input wins, otherwise the kind's default
// so the user can submit "node-status" without typing anything.
const effectiveTitle = title.trim() || kindDefaultTitle[kind];
const canSubmit = !saving && effectiveTitle.length > 0;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
{...backdrop}
>
<div
className="bg-surface rounded-md shadow-lg w-[320px] p-4 flex flex-col gap-3"
onClick={(e) => e.stopPropagation()}
>
<div className="text-sm font-semibold">{t('addWidget.title')}</div>
<label className="flex flex-col gap-1 text-[11px] text-slate-600">
{t('addWidget.kindLabel')}
<select
value={kind}
onChange={(e) => setKind(e.target.value as DashboardWidgetKind)}
disabled={saving}
className="border border-hairline rounded px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
<option value="markdown">{t('addWidget.kindMarkdown')}</option>
<option value="node-status">{t('addWidget.kindNodeStatus')}</option>
</select>
</label>
<input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={
kind === 'node-status'
? t('addWidget.titlePlaceholderNodeStatus', { example: kindDefaultTitle['node-status'] })
: t('addWidget.titlePlaceholderMarkdown')
}
maxLength={64}
className="border border-hairline rounded px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={saving}
className="px-3 py-1 text-xs border border-hairline rounded hover:bg-surface-2"
>
{t('addWidget.cancel')}
</button>
<button
type="button"
disabled={!canSubmit}
onClick={async () => {
setSaving(true);
try {
const slug = slugify(effectiveTitle, existingSlugs);
await onCreate({ slug, title: effectiveTitle, kind });
setTitle('');
setKind('markdown');
onClose();
} finally {
setSaving(false);
}
}}
className="px-3 py-1 text-xs bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50"
>
{t('addWidget.create')}
</button>
</div>
</div>
</div>
);
}
@@ -1,82 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarkdownText } from '../../lib/markdown-text';
import type { DashboardWidget } from '../../api';
interface Props {
widget: DashboardWidget;
onSave: (patch: { title?: string; content?: string }) => Promise<void>;
onDelete: () => Promise<void>;
}
export function MarkdownWidget({ widget, onSave, onDelete }: Props) {
const { t } = useTranslation('dashboard');
const [editing, setEditing] = useState(false);
const [draftContent, setDraftContent] = useState(widget.markdownContent);
const [saving, setSaving] = useState(false);
if (!editing) {
return (
<div className="relative h-full overflow-auto p-3">
<button
type="button"
onClick={() => { setDraftContent(widget.markdownContent); setEditing(true); }}
className="absolute top-2 right-2 px-2 py-1 text-[11px] bg-canvas border border-hairline rounded hover:bg-surface-2"
aria-label={t('markdown.editAria')}
>
</button>
{widget.markdownContent
? <MarkdownText text={widget.markdownContent} />
: <div className="text-xs text-slate-400 italic">{t('markdown.emptyHint')}</div>}
</div>
);
}
return (
<div className="flex flex-col h-full p-2 gap-2">
<textarea
className="flex-1 w-full border border-hairline rounded p-2 text-xs font-mono resize-none focus:outline-none focus:ring-2 focus:ring-accent-ring"
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
/>
<div className="flex items-center gap-2">
<button
type="button"
disabled={saving}
onClick={async () => {
setSaving(true);
try {
await onSave({ content: draftContent });
setEditing(false);
} finally {
setSaving(false);
}
}}
className="px-3 py-1 bg-accent text-accent-fg text-xs rounded hover:bg-accent-deep disabled:opacity-50"
>
{t('markdown.save')}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="px-3 py-1 bg-canvas border border-hairline text-xs rounded hover:bg-surface-2"
>
{t('markdown.cancel')}
</button>
<div className="flex-1" />
<button
type="button"
onClick={async () => {
if (!window.confirm(t('markdown.deleteConfirm', { title: widget.title }))) return;
await onDelete();
}}
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:hover:bg-red-500/15 rounded"
aria-label={t('markdown.deleteAria')}
>
{t('markdown.delete')}
</button>
</div>
</div>
);
}
+7 -48
View File
@@ -1,84 +1,43 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDashboardWidgets } from '../../hooks/useDashboardWidgets';
import { WidgetTabBar, WORKER_TAB_SLUG } from './WidgetTabBar';
import { WidgetTabBar, WORKER_TAB_SLUG, NODE_TAB_SLUG } from './WidgetTabBar';
import { WorkerStatusWidget } from './WorkerStatusWidget';
import { MarkdownWidget } from './MarkdownWidget';
import { NodeStatusWidget } from './NodeStatusWidget';
import { AddWidgetDialog } from './AddWidgetDialog';
interface Props {
/** Controlled-active widget slug. Defaults to worker tab. */
/** Controlled-active tab slug. Defaults to the worker tab. */
activeSlug?: string;
onActiveSlugChange?: (slug: string) => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
/**
* Side Info Panel: two fixed live-status views, worker and node (GPU).
* The configurable Markdown-widget dashboard was removed in 2026-06.
*/
export function SideInfoPanel({
activeSlug: activeSlugProp,
onActiveSlugChange,
collapsed,
onToggleCollapse,
}: Props) {
const { t } = useTranslation('dashboard');
const { widgets, create, update, remove } = useDashboardWidgets();
const [localActive, setLocalActive] = useState<string>(WORKER_TAB_SLUG);
const activeSlug = activeSlugProp ?? localActive;
const setActive = onActiveSlugChange ?? setLocalActive;
const [dialogOpen, setDialogOpen] = useState(false);
const activeWidget = widgets.find(w => w.slug === activeSlug);
return (
<div className="flex flex-col h-full overflow-hidden bg-canvas">
<WidgetTabBar
widgets={widgets}
activeSlug={activeSlug}
onSelect={setActive}
onAdd={() => setDialogOpen(true)}
onDeleteWidget={async (w) => {
if (!window.confirm(t('panel.deleteConfirm', { title: w.title }))) return;
await remove.mutateAsync(w.id);
if (activeSlug === w.slug) setActive(WORKER_TAB_SLUG);
}}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
/>
{!collapsed && (
<div className="flex-1 min-h-0 overflow-hidden">
{activeSlug === WORKER_TAB_SLUG && <WorkerStatusWidget />}
{activeSlug !== WORKER_TAB_SLUG && activeWidget && activeWidget.kind === 'node-status' && (
<NodeStatusWidget key={activeWidget.id} />
)}
{activeSlug !== WORKER_TAB_SLUG && activeWidget && activeWidget.kind !== 'node-status' && (
<MarkdownWidget
key={activeWidget.id}
widget={activeWidget}
onSave={async (patch) => {
await update.mutateAsync({ id: activeWidget.id, patch });
}}
onDelete={async () => {
await remove.mutateAsync(activeWidget.id);
setActive(WORKER_TAB_SLUG);
}}
/>
)}
{activeSlug !== WORKER_TAB_SLUG && !activeWidget && (
<div className="p-3 text-xs text-slate-500">{t('panel.widgetNotFound')}</div>
)}
{activeSlug === NODE_TAB_SLUG ? <NodeStatusWidget /> : <WorkerStatusWidget />}
</div>
)}
<AddWidgetDialog
open={dialogOpen}
existingSlugs={widgets.map(w => w.slug)}
onClose={() => setDialogOpen(false)}
onCreate={async (input) => {
await create.mutateAsync(input);
setActive(input.slug);
}}
/>
</div>
);
}
+16 -52
View File
@@ -1,25 +1,23 @@
import { useTranslation } from 'react-i18next';
import type { DashboardWidget } from '../../api';
export const WORKER_TAB_SLUG = 'worker-status';
export const NODE_TAB_SLUG = 'node-status';
interface Props {
widgets: DashboardWidget[];
activeSlug: string;
onSelect: (slug: string) => void;
onAdd: () => void;
/** Called with the widget slug when the user clicks the × on a tab. */
onDeleteWidget?: (widget: DashboardWidget) => void;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
/**
* Fixed two-tab bar for the Side Info Panel: worker status and node (GPU)
* status. The configurable-widget feature was removed (2026-06), so there is
* no add/delete/reorder — just a switch between the two live status views.
*/
export function WidgetTabBar({
widgets,
activeSlug,
onSelect,
onAdd,
onDeleteWidget,
collapsed,
onToggleCollapse,
}: Props) {
@@ -29,26 +27,13 @@ export function WidgetTabBar({
<TabButton
active={activeSlug === WORKER_TAB_SLUG}
onClick={() => onSelect(WORKER_TAB_SLUG)}
label="👷 Worker"
label={`👷 ${t('worker.tab')}`}
/>
<TabButton
active={activeSlug === NODE_TAB_SLUG}
onClick={() => onSelect(NODE_TAB_SLUG)}
label={`🖥️ ${t('nodeStatus.tab')}`}
/>
{widgets.map((w) => (
<TabButton
key={w.slug}
active={activeSlug === w.slug}
onClick={() => onSelect(w.slug)}
label={w.kind === 'node-status' ? `🖥️ ${w.title}` : w.title}
onDelete={onDeleteWidget ? () => onDeleteWidget(w) : undefined}
/>
))}
<button
type="button"
onClick={onAdd}
title={t('tabBar.addWidget')}
className="px-2 py-1 text-xs text-slate-500 hover:text-slate-800 hover:bg-surface-2 rounded"
aria-label={t('tabBar.addWidget')}
>
+
</button>
<div className="flex-1" />
{onToggleCollapse && (
<button
@@ -65,42 +50,21 @@ export function WidgetTabBar({
}
function TabButton({
active, onClick, label, onDelete,
}: { active: boolean; onClick: () => void; label: string; onDelete?: () => void }) {
const { t } = useTranslation('dashboard');
// group/tab を親に付け、× は group-hover で表示。タブ自体の active 状態でも常時表示する
// ことで、編集中のタブを誤って閉じる怖さは confirm dialog 側で吸収する。
active, onClick, label,
}: { active: boolean; onClick: () => void; label: string }) {
return (
<div className={`relative group/tab inline-flex items-center rounded ${
<div className={`relative inline-flex items-center rounded ${
active ? 'bg-accent text-accent-fg' : 'hover:bg-surface-2'
}`}>
<button
type="button"
onClick={onClick}
className={`pl-2 ${onDelete ? 'pr-1' : 'pr-2'} py-1 text-xs whitespace-nowrap ${
className={`px-2 py-1 text-xs whitespace-nowrap ${
active ? 'font-semibold' : 'text-slate-600'
}`}
>
{label}
</button>
{onDelete && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label={t('tabBar.deleteWidget')}
title={t('tabBar.delete')}
className={`mr-1 w-4 h-4 inline-flex items-center justify-center rounded text-[11px] leading-none transition-opacity ${
active
? 'opacity-70 hover:opacity-100 hover:bg-white/20'
: 'opacity-0 group-hover/tab:opacity-100 hover:bg-slate-300/60'
}`}
>
×
</button>
)}
</div>
);
}
@@ -86,7 +86,7 @@ function WorkerRow({
: null;
return (
<div>
<div data-testid={`worker-row-${workerId}`}>
<div className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-surface-2">
<div className="w-8 h-8 flex-shrink-0 flex items-center justify-center">
{showPet ? (
@@ -121,7 +121,7 @@ function WorkerRow({
)}
<span className="truncate">{name}</span>
{proxy && (
<span className="px-1 py-0.5 rounded text-[9px] font-medium bg-violet-50 dark:bg-violet-500/15 text-violet-700 dark:text-violet-300 leading-none">
<span data-testid="worker-proxy-badge" className="px-1 py-0.5 rounded text-[9px] font-medium bg-violet-50 dark:bg-violet-500/15 text-violet-700 dark:text-violet-300 leading-none">
proxy
</span>
)}
@@ -186,7 +186,7 @@ function BackendRow({
: null;
return (
<div className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-surface-2">
<div data-testid={`worker-backend-${backend.id}`} className="flex items-center gap-2 px-2 py-1 rounded-md hover:bg-surface-2">
<div className="w-6 h-6 flex-shrink-0 flex items-center justify-center">
{showPet ? (
<PetSprite
+9 -4
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DetailTabId } from '../../lib/urlState';
import { shareTask, unshareTask } from '../../api';
import { tabAppearClass } from './detailTabs';
interface Tab { id: DetailTabId; labelKey: string; }
@@ -31,7 +32,7 @@ interface DetailHeaderProps {
onContinue?: () => void;
}
function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; shareToken: string | null; onShareChange?: () => void }) {
export function ShareButton({ taskId, shareToken, onShareChange, testid }: { taskId: number; shareToken: string | null; onShareChange?: () => void; testid?: string }) {
const { t } = useTranslation('detail');
const [copied, setCopied] = useState(false);
const qc = useQueryClient();
@@ -70,6 +71,7 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh
<button
onClick={() => shareMutation.mutate()}
disabled={shareMutation.isPending}
data-testid={testid}
title={shareMutation.isPending ? t('share.sharing') : t('share.publish')}
aria-label={t('share.publish')}
className={`${iconBtnBase} border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface`}
@@ -99,7 +101,7 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh
};
return (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1" data-testid={testid}>
<button
onClick={handleCopy}
title={copied ? t('share.copied') : t('share.copy')}
@@ -139,7 +141,7 @@ function ShareButton({ taskId, shareToken, onShareChange }: { taskId: number; sh
);
}
function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string | null; onClick: () => void }) {
export function ContinueButton({ latestJobStatus, onClick, testid }: { latestJobStatus: string | null; onClick: () => void; testid?: string }) {
const { t } = useTranslation('detail');
// Mirror the spec/backend TERMINAL list (worker maps abort outcomes to
// 'failed', so 'aborted' is intentionally absent).
@@ -151,6 +153,7 @@ function ContinueButton({ latestJobStatus, onClick }: { latestJobStatus: string
<button
onClick={onClick}
disabled={!enabled}
data-testid={testid}
title={enabled ? t('continue.label') : t('continue.disabled')}
aria-label={t('continue.label')}
className={`${iconBtnBase} border-hairline bg-canvas text-slate-600 hover:text-slate-900 hover:bg-surface`}
@@ -218,6 +221,7 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
)}
<button
onClick={onClose}
data-testid="detail-panel-close"
aria-label={t('panel.close')}
className="hidden sm:inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
@@ -239,9 +243,10 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
<button
key={tab.id}
role="tab"
data-testid={`detail-tab-${tab.id}`}
aria-selected={active}
onClick={() => onTabChange(tab.id)}
className={`whitespace-nowrap pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${
className={`whitespace-nowrap pb-2.5 text-xs border-b-2 active:scale-[0.97] transition-[transform,color,border-color] duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring inline-flex items-center gap-1.5 ${tabAppearClass(tab.id)} ${
active
? 'border-accent text-slate-900 font-semibold'
: 'border-transparent text-slate-500 font-medium hover:text-slate-800'
+28 -19
View File
@@ -12,6 +12,7 @@ import { SkeletonDetailPanel } from '../shared/Skeleton';
import { OverviewTab } from './tabs/OverviewTab';
import { ProgressTab } from './tabs/ProgressTab';
import { FilesTab } from './tabs/FilesTab';
import type { FileManagement } from '../files/FileBrowser';
import { TraceTab } from './tabs/TraceTab';
import { BrowserTab } from './tabs/BrowserTab';
import { ConsoleTab } from './tabs/ConsoleTab';
@@ -40,10 +41,16 @@ interface LocalDetailPanelProps {
onViewFullLog: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
/** ファイルタブのアップロード/削除操作。undefined のとき読み取り専用。 */
fileManagement?: FileManagement;
subtaskActivities?: SubtaskActivity[];
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
shareToken?: string | null;
onShareChange?: () => void;
/** When true, omit the DetailHeader (tab row + close button) and render only
* 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;
}
@@ -51,8 +58,8 @@ export function LocalDetailPanel({
task, taskId, section, currentPath, entries, pathSegments,
loading, detailTab, detailWidth, showWidthToggle,
onTabChange, onWidthToggle, onClose, onDelete, onSectionChange, onNavigate, onPreview, onViewFullLog,
onRefresh, isRefreshing, subtaskActivities, onSubtaskFilePreview,
shareToken, onShareChange,
onRefresh, isRefreshing, fileManagement, subtaskActivities, onSubtaskFilePreview,
shareToken, onShareChange, headerless = false,
}: LocalDetailPanelProps) {
const { t } = useTranslation('detail');
// Deferred tab id for content rendering. The tab indicator (DetailHeader)
@@ -129,22 +136,24 @@ export function LocalDetailPanel({
return (
<div className="flex flex-col h-full overflow-hidden bg-surface">
<DetailHeader
title={`Task #${taskId}`}
subtitle={t('panel.subtitle')}
tabs={visibleTabs}
activeTab={detailTab}
tabTransitionPending={tabTransitionPending}
onTabChange={onTabChange}
onClose={onClose}
detailWidth={detailWidth}
onWidthToggle={showWidthToggle ? onWidthToggle : undefined}
taskId={taskId}
shareToken={shareToken}
onShareChange={onShareChange}
latestJobStatus={task?.latestJob?.status ?? null}
onContinue={task?.latestJob ? () => setContinueOpen(true) : undefined}
/>
{!headerless && (
<DetailHeader
title={`Task #${taskId}`}
subtitle={t('panel.subtitle')}
tabs={visibleTabs}
activeTab={detailTab}
tabTransitionPending={tabTransitionPending}
onTabChange={onTabChange}
onClose={onClose}
detailWidth={detailWidth}
onWidthToggle={showWidthToggle ? onWidthToggle : undefined}
taskId={taskId}
shareToken={shareToken}
onShareChange={onShareChange}
latestJobStatus={task?.latestJob?.status ?? null}
onContinue={task?.latestJob ? () => setContinueOpen(true) : undefined}
/>
)}
{continueOpen && task?.latestJob && (
<ContinueWithPieceDialog
taskId={taskId}
@@ -249,7 +258,7 @@ export function LocalDetailPanel({
)}
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} />}
{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} />}
{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} />}
{deferredDetailTab === 'browser' && <BrowserTab taskId={taskId} />}
{deferredDetailTab === 'ssh' && <ConsoleTab taskId={taskId} />}
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { tabAppearClass } from './detailTabs';
describe('tabAppearClass', () => {
it('emphasizes only the browser and ssh tabs (the conditional, session-live tabs)', () => {
expect(tabAppearClass('browser')).toBe('tab-appear-emphasis');
expect(tabAppearClass('ssh')).toBe('tab-appear-emphasis');
});
it('does not emphasize always-present tabs', () => {
for (const id of ['overview', 'activity', 'files', 'trace', 'chat'] as const) {
expect(tabAppearClass(id)).toBe('');
}
});
});
+12
View File
@@ -17,6 +17,18 @@ export const LOCAL_TABS: DetailTab[] = [
{ id: 'ssh', labelKey: 'tabs.ssh' },
];
/**
* Entrance-animation class for a tab button. Browser/SSH tabs mount exactly when
* their session goes live (see useVisibleDetailTabs), so a CSS mount animation is
* the "appearance effect" — it draws the eye to the freshly popped-in tab. All
* other tabs are always present, so they get no entrance class. Pure + shared so
* DetailHeader and the space conversation tab bar animate identically.
* The class itself respects prefers-reduced-motion in CSS (no motion / instant).
*/
export function tabAppearClass(tabId: DetailTabId | 'chat'): string {
return tabId === 'browser' || tabId === 'ssh' ? 'tab-appear-emphasis' : '';
}
/**
* The detail tabs visible for a task. Browser and SSH only appear while their
* session is live (so the tab carries the live signal). Single source of truth
+2 -1
View File
@@ -1,5 +1,5 @@
import { LocalFileEntry } from '../../../api';
import { FileBrowser } from '../../files/FileBrowser';
import { FileBrowser, type FileManagement } from '../../files/FileBrowser';
interface FilesTabProps {
section: 'workspace' | 'input' | 'output' | 'logs';
@@ -12,6 +12,7 @@ interface FilesTabProps {
onPreview: (path: string, name: string) => void;
onRefresh?: () => void;
isRefreshing?: boolean;
management?: FileManagement;
}
export function FilesTab(props: FilesTabProps) {
@@ -20,7 +20,7 @@ export function ProgressTab({ task, onViewFullLog, subtaskActivities }: Progress
const logLoading = activityLogQuery.isLoading;
return (
<div className="flex flex-col gap-3">
<div data-testid="progress-tab" className="flex flex-col gap-3">
<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('progress.timeline')}</div>
@@ -0,0 +1,51 @@
/**
* FileBreadcrumb — ファイルブラウザのパンくず(ルート / seg / seg …)。
* スペースのファイルタブとタスクのファイルタブで共有する。
*/
interface FileBreadcrumbProps {
/** 現在パスのセグメント配列(空配列 = ルート)。 */
pathSegments: string[];
/** セグメントをクリックしたときに移動するパス('' = ルート)。 */
onNavigate: (path: string) => void;
/** ルートラベル左に出す任意のプレフィックス(例: '/files')。 */
testid?: string;
}
export function FileBreadcrumb({ pathSegments, onNavigate, testid }: FileBreadcrumbProps) {
return (
<nav
data-testid={testid}
aria-label="パンくず"
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"
disabled={pathSegments.length === 0}
aria-current={pathSegments.length === 0 ? 'location' : undefined}
>
</button>
{pathSegments.map((seg, i) => {
const prefix = pathSegments.slice(0, i + 1).join('/');
const isLast = i === pathSegments.length - 1;
return (
<span key={prefix} className="flex items-center gap-0.5">
<span className="text-slate-300" aria-hidden>/</span>
<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"
disabled={isLast}
aria-current={isLast ? 'location' : undefined}
title={seg}
>
{seg}
</button>
</span>
);
})}
</nav>
);
}
+85 -116
View File
@@ -1,9 +1,31 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
import { isPreviewable, formatFileDate } from '../../lib/utils';
import { splitFileName } from '../../lib/fileType';
import { FileTypeIcon } from './FileTypeIcon';
import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTileGrid } from './FileTileGrid';
import { FileActions, FileSelectionBar, FileDropzone } from './FileToolbar';
/**
* タスク詳細のファイルタブで使う書込(アップロード/削除)操作一式。
* `useFileBrowser` の戻り値から組み立てて渡す。undefined のときは読み取り専用
* (共有ビュー等)で、ツールバー・選択・ドロップゾーンを出さない。
*/
export interface FileManagement {
/** owner/admin か(UI でツールバーを出すか)。サーバ側でも再ゲートされる。 */
canManage: boolean;
/** 現在の区分がアップロード/削除可能か(input/output のみ true)。 */
writableSection: boolean;
selected: Set<string>;
toggleSelect: (path: string) => void;
toggleSelectAll: (paths: string[]) => void;
upload: (files: File[]) => void;
remove: (paths: string[]) => void;
download: (paths: string[]) => void;
isUploading: boolean;
isDeleting: boolean;
isDownloading: boolean;
message: { text: string; kind: 'ok' | 'error' } | null;
}
interface FileBrowserProps {
section: 'workspace' | 'input' | 'output' | 'logs';
@@ -16,6 +38,7 @@ interface FileBrowserProps {
onPreview: (path: string, name: string) => void;
onRefresh?: () => void;
isRefreshing?: boolean;
management?: FileManagement;
}
type FileSort = 'name' | 'newest';
@@ -123,8 +146,6 @@ function sortEntries(entries: LocalFileEntry[], mode: FileSort): LocalFileEntry[
const files = entries.filter(e => e.kind !== 'directory');
const sortFn = mode === 'newest'
? (a: LocalFileEntry, b: LocalFileEntry) => {
// Files: by modifiedAt desc. Directories: same when timestamps exist,
// else fall back to name so the order is stable.
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;
@@ -145,19 +166,22 @@ export function FileBrowser({
onPreview,
onRefresh,
isRefreshing,
management,
}: 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]);
// Icon-only action buttons. Replaces the wider "Preview" / "DL" / "Open"
// text buttons that were squeezing long filenames before. Sized 32px for
// finger-friendly tap targets on iPhone (still compact on desktop).
const iconBtn = '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';
// 書込ツールバーを出す条件: 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;
return (
<div className="flex flex-col gap-3">
{/* 区分タブ + アクション(追加 / 再読み込み) */}
<div className="flex gap-1 flex-wrap items-center">
{SECTIONS.map(s => (
<button
@@ -172,22 +196,19 @@ export function FileBrowser({
{s}
</button>
))}
{onRefresh && (
<button
onClick={onRefresh}
disabled={isRefreshing}
className={`ml-auto ${iconBtn} disabled:opacity-50`}
title={t('refresh')}
aria-label={t('refresh')}
>
<svg className={`w-3.5 h-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" />
<path d="M12 2v3h-3M4 14v-3h3" />
</svg>
</button>
)}
<div className="ml-auto">
<FileActions
idPrefix="task"
canUpload={canWrite}
onUploadFiles={files => management?.upload(files)}
onRefresh={() => onRefresh?.()}
isRefreshing={isRefreshing}
isUploading={management?.isUploading}
/>
</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}` : ''}
@@ -195,101 +216,49 @@ export function FileBrowser({
<FileSortMenu sort={sort} onChange={setSort} />
</div>
{pathSegments.length > 0 && (
<button
onClick={() => onNavigate(pathSegments.slice(0, -1).join('/'))}
className="self-start inline-flex items-center gap-1 px-2 h-7 rounded border border-hairline bg-canvas text-2xs text-slate-600 hover:bg-surface transition-colors"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 4l-4 4 4 4M6 8h6" />
</svg>
Up
</button>
<FileBreadcrumb testid="task-files-breadcrumb" pathSegments={pathSegments} onNavigate={onNavigate} />
{canWrite && filePaths.length > 0 && (
<FileSelectionBar
idPrefix="task"
allSelected={allSelected}
onToggleSelectAll={() => management?.toggleSelectAll(filePaths)}
selectedCount={selectedInView.length}
onDeleteSelected={() => management?.remove(selectedInView)}
onDownloadSelected={() => management?.download(selectedInView)}
isDeleting={management?.isDeleting}
isDownloading={management?.isDownloading}
/>
)}
<div className="flex flex-col gap-1">
{sortedEntries.map(entry => (
<div
key={`${entry.kind}:${entry.path}`}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-canvas border border-hairline hover:bg-surface transition-colors"
>
<span className="flex-shrink-0" aria-hidden="true">
{entry.kind === 'directory' ? (
<svg className="w-4 h-4 text-slate-400" 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 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} />
)}
</span>
<div className="min-w-0 flex-1">
{entry.kind === 'file'
? (() => {
// Truncate the stem but never the extension, so the file
// type stays readable on long names: `long-na…me.xlsx`.
const { stem, ext } = splitFileName(entry.name);
return (
<div className="flex items-baseline text-[13px] text-slate-800 min-w-0" title={entry.name}>
<span className="truncate min-w-0">{stem}</span>
{ext && <span className="flex-shrink-0">{ext}</span>}
</div>
);
})()
: <div className="text-[13px] text-slate-800 truncate" title={entry.name}>{entry.name}</div>
}
{entry.kind === 'file' && entry.modifiedAt && (
<div className="text-[10px] text-slate-400 font-mono leading-tight">{formatFileDate(entry.modifiedAt)}</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{entry.kind === 'directory' ? (
<button
onClick={() => onNavigate(entry.path)}
className={iconBtn}
title="Open folder"
aria-label="Open folder"
>
<svg className="w-3.5 h-3.5" 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>
) : (
<>
{isPreviewable(entry.name) && (
<button
onClick={() => onPreview(entry.path, entry.name)}
className={iconBtn}
title="Preview"
aria-label="Preview"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1.5 8s2.5-5 6.5-5 6.5 5 6.5 5-2.5 5-6.5 5-6.5-5-6.5-5z" />
<circle cx="8" cy="8" r="2" />
</svg>
</button>
)}
{taskId != null && (
<a
href={getLocalFileRawUrl(taskId, section, entry.path)}
download={entry.name}
className={iconBtn}
title="Download"
aria-label="Download"
>
<svg className="w-3.5 h-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>
</a>
)}
</>
)}
</div>
</div>
))}
{entries.length === 0 && (
<div className="text-xs text-slate-500 px-1 py-2">{t('empty')}</div>
)}
</div>
{management?.message && (
<p className={`text-xs ${management.message.kind === 'ok' ? 'text-emerald-600' : 'text-red-600'}`}>
{management.message.text}
</p>
)}
<FileDropzone
idPrefix="task"
enabled={canWrite}
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')}
/>
</FileDropzone>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
/**
* FileTileGrid — ファイル/ディレクトリのアイコングリッド(auto-fill タイル)。
* スペースのファイルタブとタスクのファイルタブで共有する純プレゼンテーショナル部品。
* 状態は一切持たず、表示と入力イベントの中継だけを行う。
*
* testid は `idPrefix` から組み立てる('space' → space-files-grid / space-file-tile …)。
* 既存 e2e の testid を保つため、idPrefix を変えるだけで両者を再現できるようにしている。
*/
import { LocalFileEntry } from '../../api';
import { FileTypeIcon } from './FileTypeIcon';
interface FileTileGridProps {
/** 表示するエントリ(呼出側でソート済み)。 */
entries: LocalFileEntry[];
/** 'space' | 'task' などの testid 接頭辞。 */
idPrefix: string;
/** ファイルに選択チェック + 個別削除ボタンを出すか(owner/admin かつ書込可のとき true)。 */
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;
/**
* ファイルの生 URL を返す。指定すると各ファイルタイルに個別ダウンロード
* `<a download>`)を出す。ダウンロードは閲覧操作なので canManage に関係なく出す。
*/
fileHref?: (entry: LocalFileEntry) => string;
/** タイル下部に差し込む任意のオーバーレイ(例: スペースの「アプリとして実行」)。 */
renderTileOverlay?: (entry: LocalFileEntry) => React.ReactNode;
/** エントリ 0 件のとき表示するヒント。 */
emptyHint?: React.ReactNode;
}
export function FileTileGrid({
entries,
idPrefix,
canManage,
selected,
onToggleSelect,
onOpenDir,
onOpenFile,
onDeleteOne,
isDeleting,
fileHref,
renderTileOverlay,
emptyHint,
}: FileTileGridProps) {
return (
<div
data-testid={`${idPrefix}-files-grid`}
className="grid grid-cols-[repeat(auto-fill,minmax(80px,1fr))] gap-1 sm:grid-cols-[repeat(auto-fill,minmax(92px,1fr))]"
>
{entries.map(entry => {
const isFile = entry.kind !== 'directory';
const isChecked = isFile && selected.has(entry.path);
const showControls = isFile && canManage;
return (
<div
key={`${entry.kind}:${entry.path}`}
className={`group relative rounded-lg border transition-colors ${
isChecked ? 'border-[var(--brand-primary)] bg-surface' : 'border-transparent hover:border-hairline hover:bg-surface'
}`}
>
<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 w-full flex-col items-center gap-1.5 rounded-lg px-1.5 py-2.5 text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
>
<span className="flex h-9 w-9 items-center justify-center text-slate-400" aria-hidden>
{entry.kind === 'directory' ? (
<svg className="h-9 w-9 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-9 w-9" />
)}
</span>
<span
className="line-clamp-2 w-full break-words text-[11px] leading-tight text-slate-700"
style={{ wordBreak: 'break-word' }}
>
{entry.name}
</span>
</button>
{renderTileOverlay?.(entry)}
{showControls && (
<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 ${
isChecked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100'
}`}
/>
)}
{/* 右上クラスタ: ダウンロード(全員)+ 削除(編集権あり時)。ファイルのみ。 */}
{isFile && (fileHref || showControls) && (
<div className="absolute right-1 top-1 flex items-center gap-0.5">
{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"
>
<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 && (
<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"
>
<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>
)}
</div>
);
})}
{entries.length === 0 && emptyHint && (
<div className="col-span-full px-1 py-6 text-center text-xs text-slate-500">
{emptyHint}
</div>
)}
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
/**
* ファイルブラウザのツールバー部品群(スペース / タスク共有)。
* いずれも状態を持たない純プレゼンテーショナル。drag 状態だけ FileDropzone が内部に持つ。
*
* - FileActions … 「追加」ボタン + 隠し input + 再読み込み
* - FileSelectionBar … すべて選択 + 選択件数 + 削除(複数選択)
* - FileDropzone … 子をラップし、ドラッグ&ドロップでアップロードを受ける
*/
import { useEffect, useRef, useState } from 'react';
export { filesToBase64 } from '../../lib/fileBase64';
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';
export function FileActions({
idPrefix,
canUpload,
onUploadFiles,
onRefresh,
isRefreshing,
isUploading,
}: {
idPrefix: string;
canUpload: boolean;
onUploadFiles: (files: File[]) => void;
onRefresh: () => void;
isRefreshing?: boolean;
isUploading?: boolean;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex shrink-0 items-center gap-1.5">
{canUpload && (
<>
<input
ref={inputRef}
type="file"
multiple
data-testid={`${idPrefix}-files-upload-input`}
className="hidden"
onChange={e => {
const files = Array.from(e.target.files ?? []);
e.target.value = '';
onUploadFiles(files);
}}
/>
<button
type="button"
data-testid={`${idPrefix}-files-upload-btn`}
onClick={() => inputRef.current?.click()}
disabled={isUploading}
className="inline-flex h-8 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:opacity-50"
>
<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>
</button>
</>
)}
<button
type="button"
onClick={onRefresh}
disabled={isRefreshing}
className={`${ICON_BTN} disabled:opacity-50`}
title="再読み込み"
aria-label="再読み込み"
>
<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" />
<path d="M12 2v3h-3M4 14v-3h3" />
</svg>
</button>
</div>
);
}
export function FileSelectionBar({
idPrefix,
allSelected,
onToggleSelectAll,
selectedCount,
onDeleteSelected,
onDownloadSelected,
isDeleting,
isDownloading,
}: {
idPrefix: string;
allSelected: boolean;
onToggleSelectAll: () => void;
selectedCount: number;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
isDeleting?: boolean;
isDownloading?: boolean;
}) {
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">
<input
type="checkbox"
data-testid={`${idPrefix}-files-select-all`}
checked={allSelected}
onChange={onToggleSelectAll}
className="h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)]"
/>
</label>
<span data-testid={`${idPrefix}-files-selected-count`} className="text-slate-400">
{selectedCount}
</span>
{selectedCount > 0 && (
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
data-testid={`${idPrefix}-files-download-selected`}
onClick={onDownloadSelected}
disabled={isDownloading}
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="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
</svg>
</button>
<button
type="button"
data-testid={`${idPrefix}-files-delete-selected`}
onClick={onDeleteSelected}
disabled={isDeleting}
className="inline-flex h-7 items-center gap-1 rounded-md border border-red-200 bg-red-50 px-2.5 text-2xs font-bold text-red-700 transition-colors hover:bg-red-100 disabled:opacity-50 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
</svg>
</button>
</div>
)}
</div>
);
}
export function FileDropzone({
idPrefix,
enabled,
isUploading,
onDropFiles,
onRejectFolder,
children,
}: {
idPrefix: string;
/** false のときドロップを受け付けない(オーバーレイも出さない)。 */
enabled: boolean;
isUploading?: boolean;
onDropFiles: (files: File[]) => void;
/** フォルダがドロップされたときのコールバック(未対応メッセージ表示用)。 */
onRejectFolder?: () => void;
children: React.ReactNode;
}) {
const [dragDepth, setDragDepth] = useState(0);
// ウィンドウ外への drag 離脱や drop 取りこぼしで dragDepth が戻らずオーバーレイが
// 残るのを防ぐ。window レベルの dragend/drop で必ず 0 に戻す。
useEffect(() => {
const reset = () => setDragDepth(0);
window.addEventListener('dragend', reset);
window.addEventListener('drop', reset);
return () => {
window.removeEventListener('dragend', reset);
window.removeEventListener('drop', reset);
};
}, []);
// testid と要素は常に出し、enabled=falseread-only)のときは DnD ハンドラと
// オーバーレイだけ無効化する(既存 e2e の dropzone testid を温存する)。
return (
<div
data-testid={`${idPrefix}-files-dropzone`}
className="relative min-h-[6rem] rounded-lg"
onDragEnter={enabled ? (e => { e.preventDefault(); setDragDepth(d => d + 1); }) : undefined}
onDragOver={enabled ? (e => { e.preventDefault(); }) : undefined}
onDragLeave={enabled ? (e => { e.preventDefault(); if (!e.relatedTarget) { setDragDepth(0); } else { setDragDepth(d => Math.max(0, d - 1)); } }) : undefined}
onDrop={enabled ? (e => {
e.preventDefault();
setDragDepth(0);
const hasDirectory = Array.from(e.dataTransfer.items ?? []).some(
it => it.webkitGetAsEntry?.()?.isDirectory,
);
const files = Array.from(e.dataTransfer.files);
if (hasDirectory) { onRejectFolder?.(); return; }
if (files.length === 0) return;
onDropFiles(files);
}) : undefined}
>
{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">
</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">
</div>
)}
{children}
</div>
);
}
+16 -5
View File
@@ -43,6 +43,22 @@ const NAV_ICONS: Record<PageId, ReactNode> = {
<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" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
),
calendar: (
<svg {...ICON_PROPS}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="8" y1="2" x2="8" y2="5" />
<line x1="16" y1="2" x2="16" y2="5" />
</svg>
),
schedules: (
<svg {...ICON_PROPS}>
<circle cx="12" cy="12" r="9" />
@@ -81,11 +97,6 @@ const NAV_ICONS: Record<PageId, ReactNode> = {
<line x1="12" y1="17" x2="12" y2="17.01" />
</svg>
),
userfolder: (
<svg {...ICON_PROPS}>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
</svg>
),
usage: (
<svg {...ICON_PROPS}>
<line x1="4" y1="20" x2="20" y2="20" />
+3 -1
View File
@@ -26,6 +26,8 @@ interface TopBarProps {
// 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 },
{ id: 'pieces', labelKey: 'nav.pieces', adminOnly: false, requiresAuth: false },
{ id: 'captcha', labelKey: 'nav.captcha', adminOnly: true, requiresAuth: false },
@@ -33,7 +35,6 @@ export const NAV_ITEMS: Array<{ id: PageId; labelKey: string; adminOnly: boolean
{ id: 'settings', labelKey: 'nav.settings', adminOnly: false, requiresAuth: false },
{ id: 'users', labelKey: 'nav.users', adminOnly: true, requiresAuth: true },
{ id: 'help', labelKey: 'nav.help', adminOnly: false, requiresAuth: false },
{ id: 'userfolder', labelKey: 'nav.userfolder', adminOnly: false, requiresAuth: false },
];
/**
@@ -197,6 +198,7 @@ export function TopBar({
<button
key={item.id}
type="button"
data-testid={`nav-${item.id}`}
onClick={() => onNavigate(item.id)}
aria-current={active ? 'page' : undefined}
className={`relative whitespace-nowrap px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
+6 -33
View File
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { LocalTask } from '../../api';
import { matchText } from '../../lib/utils';
import { COLUMN_LIST, SortMode, StatusColumn } from '../../lib/urlState';
import { filterAndSortTasks, groupTasksByStatus, statusCounts, taskMatchesQuery, totalTaskCount } from '../../lib/taskFilter';
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
import { FilterBar } from './FilterBar';
import { LocalTaskListItem } from './TaskListItem';
@@ -100,9 +100,7 @@ export function TaskListPanel({
}, {} as Record<string, LocalTask[]>);
const allRail = Object.values(localColumnsRail).flat();
const baseRail = selectedStatus === 'all' ? allRail : localColumnsRail[selectedStatus] ?? [];
const filteredRail = baseRail.filter(t =>
matchText(t.title, searchQuery) || matchText(t.body, searchQuery) || matchText(t.pieceName, searchQuery) || matchText(t.ownerName ?? '', searchQuery),
);
const filteredRail = baseRail.filter(t => taskMatchesQuery(t, searchQuery));
return (
<RailPanel
tasks={filteredRail}
@@ -113,35 +111,10 @@ export function TaskListPanel({
/>
);
}
const localColumns: Record<string, LocalTask[]> = COLUMN_LIST.reduce((acc, s) => {
acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s);
return acc;
}, {} as Record<string, LocalTask[]>);
const allLocalTasks = Object.values(localColumns).flat();
const baseList: LocalTask[] =
selectedStatus === 'all' ? allLocalTasks : localColumns[selectedStatus] ?? [];
const filtered = baseList.filter(t =>
matchText(t.title, searchQuery) || matchText(t.body, searchQuery) || matchText(t.pieceName, searchQuery) || matchText(t.ownerName ?? '', searchQuery)
).sort((a, b) => {
if (sortMode === 'title') {
return a.title.localeCompare(b.title);
}
if (sortMode === 'status') {
const aStatus = a.latestJob?.status ?? 'queued';
const bStatus = b.latestJob?.status ?? 'queued';
return aStatus.localeCompare(bStatus);
}
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
const counts: Record<string, number> = {};
for (const s of COLUMN_LIST) {
counts[s] = localColumns[s]?.length ?? 0;
}
const totalCount = allLocalTasks.length;
const localColumns = groupTasksByStatus(localTasks);
const filtered = filterAndSortTasks(localTasks, selectedStatus, searchQuery, sortMode);
const counts = statusCounts(localColumns);
const totalCount = totalTaskCount(localColumns);
const runningCount = counts.running ?? 0;
const waitingCount = (counts.waiting_human ?? 0) + (counts.waiting_subtasks ?? 0);
const failedCount = counts.failed ?? 0;
+36 -5
View File
@@ -12,7 +12,6 @@ import { ToolsForm } from './ToolsForm';
import { ToolsWebForm } from './ToolsWebForm';
import { ToolsMediaForm } from './ToolsMediaForm';
import { ToolsExternalForm } from './ToolsExternalForm';
import { KnowledgeNamespacesForm } from './KnowledgeNamespacesForm';
import { AskSubtasksForm } from './AskSubtasksForm';
import { SearchFilterForm } from './SearchFilterForm';
import { BrowserSettingsForm } from './BrowserSettingsForm';
@@ -28,10 +27,11 @@ import { ReflectionForm } from './ReflectionForm';
import { McpForm } from './McpForm';
import { SshForm } from './SshForm';
import { GatewayServerForm } from './GatewayServerForm';
import { NotesForm } from './NotesForm';
import { PushNotificationsForm } from './PushNotificationsForm';
import { AuthForm } from './AuthForm';
import { OrgsForm } from './OrgsForm';
import { PetsForm } from './PetsForm';
import { useToast } from '../../hooks/useToast';
import { useAuthState } from '../../App';
@@ -55,6 +55,37 @@ function PreferencesFormWrapper() {
);
}
/**
* Pets is a per-user concept (chat mascot + worker/backend assignment), so it
* uses its own /api/users/me/pets endpoints — not the admin config draft. It
* renders stand-alone (no global save bar) with a local toast for import/save
* feedback, mirroring the other user-scoped sections.
*/
function PetsFormWrapper() {
const auth = useAuthState();
const { toast, showToast } = useToast();
// Pets work for the logged-in user OR the synthetic local user when auth is
// disabled (mode 'disabled'). Only block when auth is ON but nobody is logged
// in. ('loading' falls through to the panel, which shows its own spinner.)
if (auth.mode === 'unauthenticated') {
return <div className="text-sm text-slate-500">Log in to manage pets.</div>;
}
return (
<div className="relative h-full">
<PetsForm showToast={showToast} />
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 rounded-md px-4 py-2 text-sm text-white shadow-lg ${
toast.variant === 'error' ? 'bg-red-600' : 'bg-slate-800'
}`}
>
{toast.message}
</div>
)}
</div>
);
}
/** Set a value at a dot-separated path in an object (immutable). */
function setNestedValue(obj: any, path: string, value: any): any {
const keys = path.split('.');
@@ -95,6 +126,9 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
if (section === 'memory-learning') {
return <div className="max-w-2xl"><MemoryLearningForm /></div>;
}
if (section === 'pets') {
return <PetsFormWrapper />;
}
if (!isAdmin) {
return <div className="max-w-2xl text-sm text-slate-500">{t('configForm.adminOnly')}</div>;
}
@@ -204,7 +238,6 @@ function ConfigFormInner({ section }: ConfigFormProps) {
case 'context': return <ContextForm {...formProps} />;
case 'safety': return <SafetyForm {...formProps} />;
case 'reflection': return <ReflectionForm {...formProps} />;
case 'notes': return <NotesForm {...formProps} />;
case 'push-notifications': return <PushNotificationsForm {...formProps} />;
case 'auth': return <AuthForm {...formProps} />;
@@ -224,8 +257,6 @@ function ConfigFormInner({ section }: ConfigFormProps) {
return <ToolsMediaForm {...formProps} />;
case 'tools-external':
return <ToolsExternalForm {...formProps} />;
case 'tools-legacy-knowledge':
return <KnowledgeNamespacesForm {...formProps} />;
// ── MCP & Connections
case 'mcp': return <McpForm {...formProps} />;
@@ -1,71 +0,0 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { NamespaceEditor } from './NamespaceEditor';
import type { SectionFormProps } from './types';
/**
* Legacy Knowledge (DKS) namespace settings.
*
* Replaces the `knowledge` tab of the legacy grab-bag `ToolsForm`.
* The config keys are unchanged:
*
* tools.knowledge_service_url
* tools.knowledge_namespaces
*
* Marked as legacy in PR #357; new knowledge integrations should go
* through MCP servers. Existing namespaces remain editable / removable,
* but adding new namespaces is disabled in the editor.
*/
export function KnowledgeNamespacesForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const tools = config.tools ?? {};
return (
<div className="space-y-5">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-slate-800">Knowledge (DKS)</h2>
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30"
title={t('knowledgeDks.legacyBadgeTitle')}
>
LEGACY
</span>
</div>
<div
role="note"
className="rounded border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 px-3 py-2 text-xs text-amber-900 dark:text-amber-300"
>
{t('knowledgeDks.noteBody')}
<a
href="/help"
className="underline text-amber-900 dark:text-amber-300 hover:text-amber-700 dark:hover:text-amber-300"
target="_blank"
rel="noopener noreferrer"
>
{t('knowledgeDks.mcpGuideLink')}
</a>
</div>
<div>
<FieldLabel>Knowledge Service URL</FieldLabel>
<FieldInput value={tools.knowledgeServiceUrl ?? ''} onChange={v => onChange('tools.knowledgeServiceUrl', v)}
placeholder="http://dks-server:8100" />
<HelpText>{t('knowledgeDks.serviceUrlHelp')}</HelpText>
</div>
<div>
<FieldLabel>Knowledge Namespaces</FieldLabel>
<NamespaceEditor
value={tools.knowledgeNamespaces ?? {}}
onChange={v => onChange('tools.knowledgeNamespaces', v)}
addDisabled
addDisabledReason={t('knowledgeDks.addDisabledReason')}
addDisabledHref="/help"
/>
<HelpText>{t('knowledgeDks.namespacesHelp')}</HelpText>
</div>
</div>
);
}
@@ -1,109 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
interface NamespaceEditorProps {
value: Record<string, { apiKey: string }>;
onChange: (value: Record<string, { apiKey: string }>) => void;
/**
* Disable the "add namespace" controls (input fields + button). Existing
* entries remain editable / removable. Used by the DKS [LEGACY] section
* to steer new integrations toward MCP servers.
*/
addDisabled?: boolean;
/** Tooltip shown on the disabled controls. */
addDisabledReason?: string;
/** Optional href surfaced alongside the tooltip (e.g. MCP help doc). */
addDisabledHref?: string;
}
export function NamespaceEditor({
value,
onChange,
addDisabled = false,
addDisabledReason,
addDisabledHref,
}: NamespaceEditorProps) {
const { t } = useTranslation('settings');
const [newName, setNewName] = useState('');
const [newApiKey, setNewApiKey] = useState('');
const entries = Object.entries(value);
const handleAdd = () => {
if (addDisabled) return;
const name = newName.trim();
if (!name || name in value) return;
onChange({ ...value, [name]: { apiKey: newApiKey } });
setNewName('');
setNewApiKey('');
};
const handleRemove = (name: string) => {
const { [name]: _, ...rest } = value;
onChange(rest);
};
const handleApiKeyChange = (name: string, apiKey: string) => {
onChange({ ...value, [name]: { apiKey } });
};
const disabledTitle = addDisabled ? addDisabledReason : undefined;
return (
<div className="space-y-2">
{entries.map(([name, { apiKey }]) => (
<div key={name} className="flex items-center gap-2">
<span className="text-sm text-slate-700 min-w-[140px] truncate" title={name}>{name}</span>
<input
type="password"
value={apiKey}
onChange={e => handleApiKeyChange(name, e.target.value)}
placeholder="API Key"
className="flex-1 px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none"
/>
<button
onClick={() => handleRemove(name)}
className="text-slate-400 hover:text-red-500 text-lg leading-none px-1"
>&times;</button>
</div>
))}
<div className="flex gap-1">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
placeholder="namespace"
disabled={addDisabled}
title={disabledTitle}
className="w-[140px] px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed"
/>
<input
type="password"
value={newApiKey}
onChange={e => setNewApiKey(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleAdd(); } }}
placeholder="API Key"
disabled={addDisabled}
title={disabledTitle}
className="flex-1 px-3 py-1.5 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none disabled:bg-slate-100 disabled:text-slate-400 disabled:cursor-not-allowed"
/>
<button
onClick={handleAdd}
disabled={addDisabled}
title={disabledTitle}
className="px-3 py-1.5 text-sm bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:bg-slate-200 disabled:text-slate-400 disabled:cursor-not-allowed disabled:hover:bg-slate-200"
aria-label={addDisabled ? t('namespaceEditor.addAriaDisabled') : t('namespaceEditor.addAria')}
>{t('namespaceEditor.add')}</button>
{addDisabled && addDisabledHref && (
<a
href={addDisabledHref}
target="_blank"
rel="noopener noreferrer"
className="px-2 py-1.5 text-xs text-accent underline self-center"
title={disabledTitle}
>{t('namespaceEditor.mcpGuide')}</a>
)}
</div>
</div>
);
}
-50
View File
@@ -1,50 +0,0 @@
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import type { SectionFormProps } from './types';
/**
* Shared Knowledge Notes injection budget (`notes.inject.*`). Controls how much
* of a subscribed note is injected into the agent's context per job.
*/
export function NotesForm({ config, onChange }: SectionFormProps) {
const { t } = useTranslation('settings');
const inject = config.notes?.inject ?? {};
return (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">Notes Injection</h2>
<p className="text-[13px] text-slate-500">
{t('notes.intro')}
</p>
<div>
<FieldLabel>Per-Note Max (KB)</FieldLabel>
<FieldInput type="number" value={inject.perNoteMaxKb ?? ''}
onChange={v => onChange('notes.inject.perNoteMaxKb', v ? Number(v) : undefined)} />
<HelpText>{t('notes.perNoteHelp')}</HelpText>
</div>
<div>
<FieldLabel>Total Max (KB)</FieldLabel>
<FieldInput type="number" value={inject.totalMaxKb ?? ''}
onChange={v => onChange('notes.inject.totalMaxKb', v ? Number(v) : undefined)} />
<HelpText>{t('notes.totalHelp')}</HelpText>
</div>
<div>
<FieldLabel>{t('notes.overBudgetLabel')}</FieldLabel>
<select
value={inject.overBudgetStrategy ?? 'skip_remaining'}
onChange={e => onChange('notes.inject.overBudgetStrategy', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
>
<option value="skip_remaining">{t('notes.skipRemaining')}</option>
<option value="truncate_last">{t('notes.truncateLast')}</option>
<option value="degrade_to_search">{t('notes.degradeToSearch')}</option>
</select>
<HelpText>{t('notes.overBudgetHelp')}</HelpText>
</div>
</div>
);
}
@@ -1,3 +1,14 @@
/**
* PetsForm.tsx Settings Pets
*
* User Folder Pets Pet
* per-user + worker/backend
* Settings
*
* i18n `userfolder` `pets.*` 使
*
* /api/users/me/pets
*/
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -139,7 +150,7 @@ function WorkerMappingRow({
);
}
export function PetsPanel({ showToast }: { showToast?: ShowToast }) {
export function PetsForm({ showToast }: { showToast?: ShowToast }) {
const { t } = useTranslation('userfolder');
const inputRef = useRef<HTMLInputElement>(null);
const [importError, setImportError] = useState<string | null>(null);
@@ -260,7 +271,7 @@ export function PetsPanel({ showToast }: { showToast?: ShowToast }) {
};
return (
<div className="h-full overflow-y-auto">
<div className="h-full overflow-y-auto" data-testid="pets-form">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('pets.title')}</h2>
+20 -8
View File
@@ -18,11 +18,19 @@ export interface PieceEditorProps {
* so a deep link without pieceSource still renders correctly for non-admins.
*/
source?: 'builtin' | 'user-custom' | 'global-custom';
/**
* When set, the editor operates on the space folder's custom pieces
* (`data/spaces/{id}/pieces/`). The host (space settings) manages selection
* via local state, so `onDeleted` is used to clear it instead of URL state.
*/
spaceId?: string;
/** Called after a successful delete so the host can clear its selection. */
onDeleted?: () => void;
}
export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps) {
export function PieceEditor({ name, isAdmin = true, source, spaceId, onDeleted }: PieceEditorProps) {
const { t } = useTranslation('settings');
const { data: fetchResult, isLoading, error } = usePiece(name, source);
const { data: fetchResult, isLoading, error } = usePiece(name, source, spaceId);
// Use the server-resolved source as the authoritative value; fall back to the
// prop only while the fetch hasn't completed yet (avoids flicker on known paths).
const piece = fetchResult?.piece ?? null;
@@ -175,9 +183,9 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
setSaving(true);
try {
await updatePiece(name, saveData, effectiveSource);
await queryClient.invalidateQueries({ queryKey: ['piece', name] });
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
await updatePiece(name, saveData, effectiveSource, spaceId);
await queryClient.invalidateQueries({ queryKey: ['piece', spaceId ?? null, name] });
await queryClient.invalidateQueries({ queryKey: ['pieces', spaceId ?? null] });
setIsDirty(false);
if (editMode === 'yaml') {
setDraft(saveData);
@@ -193,9 +201,13 @@ export function PieceEditor({ name, isAdmin = true, source }: PieceEditorProps)
const handleDelete = async () => {
if (!confirm(t('pieceEditor.confirmDelete', { name }))) return;
try {
await deletePiece(name, effectiveSource);
await queryClient.invalidateQueries({ queryKey: ['pieces'] });
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
await deletePiece(name, effectiveSource, spaceId);
await queryClient.invalidateQueries({ queryKey: ['pieces', spaceId ?? null] });
if (spaceId) {
onDeleted?.();
} else {
setUrlState((prev) => ({ ...prev, piece: undefined, section: 'provider' as any }));
}
} catch (e: any) {
showToast(t('pieceEditor.toastError', { msg: e.message }), { isError: true, duration: 3000 });
}
@@ -95,6 +95,20 @@ export function ServerTlsForm({ config, onChange }: SectionFormProps) {
/>
<span>{t('serverTls.httpRedirect')}</span>
</label>
<HelpText>{t('serverTls.httpRedirectHelp')}</HelpText>
</div>
{/* HSTS (opt-in) */}
<div>
<label className="inline-flex items-center gap-2 text-[13px] text-slate-700 dark:text-slate-200">
<input
type="checkbox"
checked={tls.hsts === true}
onChange={e => onChange('server.tls.hsts', e.target.checked)}
/>
<span>{t('serverTls.hsts')}</span>
</label>
<HelpText>{t('serverTls.hstsHelp')}</HelpText>
</div>
{/* HTTP redirect port(s) — accepts a single port or a comma-separated list */}
@@ -20,12 +20,16 @@ interface SettingsSidebarProps {
* their new homes by `LEGACY_SECTION_REDIRECT` in this file. This keeps
* old bookmarks/links working through the transition.
*/
const CONFIG_GROUPS = [
export const CONFIG_GROUPS = [
{
label: 'User',
// Per-user personal settings. Labeled "Preference" so per-user items
// (mascot Pets, notifications, reflection history) are clearly grouped
// and discoverable — users previously could not find Pets under "User".
label: 'Preference',
sections: [
{ id: 'preferences', label: 'Preferences' },
{ id: 'notifications', label: '🔔 Notifications' },
{ id: 'pets', label: '◉ Pets' },
{ id: 'memory-learning', label: '🧠 Reflection history', labelKey: 'memoryLearning.navLabel' },
],
},
@@ -61,7 +65,6 @@ const CONFIG_GROUPS = [
{ id: 'context', label: 'Context' },
{ id: 'safety', label: 'Safety' },
{ id: 'reflection', label: 'Reflection' },
{ id: 'notes', label: 'Notes Injection' },
],
},
{
@@ -73,7 +76,6 @@ const CONFIG_GROUPS = [
{ id: 'tools-media', label: 'Media & Documents' },
{ id: 'tools-external', label: 'External Services' },
{ id: 'search-filter', label: 'Search Filter' },
{ id: 'tools-legacy-knowledge', label: 'Legacy Knowledge' },
],
},
{
@@ -141,7 +143,7 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: Set
{group.label}
</div>
{group.sections.map(s => (
<button key={s.id} onClick={() => onSelectSection(s.id)}
<button key={s.id} data-testid={`settings-nav-${s.id}`} onClick={() => onSelectSection(s.id)}
className={`block w-full text-left px-2 py-1 rounded text-xs mb-0.5 transition-colors ${
activeSection === s.id
? 'bg-accent-soft text-accent font-semibold'
+43 -31
View File
@@ -47,7 +47,15 @@ function SeverityBadge({ severity }: { severity: string }) {
// ── Main Component ───────────────────────────────────────────────────────────
export function SkillsForm() {
interface SkillsFormProps {
/**
* When set, operates on the space folder's skills (`data/spaces/{id}/skills/`)
* instead of the per-user folder. System skills are still surfaced read-only.
*/
spaceId?: string;
}
export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
const qc = useQueryClient();
const auth = useAuthState();
const isAdmin = auth.mode === 'authenticated' && auth.user.role === 'admin';
@@ -64,23 +72,24 @@ export function SkillsForm() {
// ── Queries ──────────────────────────────────────────────────────────────
const skillsKey = ['skills', spaceId ?? null];
const skillsQuery = useQuery<SkillSummary[]>({
queryKey: ['skills'],
queryFn: () => fetchSkills(),
queryKey: skillsKey,
queryFn: () => fetchSkills(undefined, spaceId),
});
const detailQuery = useQuery<SkillDetail>({
queryKey: ['skill-detail', selected],
queryFn: () => fetchSkillDetail(selected!),
queryKey: ['skill-detail', spaceId ?? null, selected],
queryFn: () => fetchSkillDetail(selected!, undefined, spaceId),
enabled: !!selected && !newMode,
});
// ── Mutations ────────────────────────────────────────────────────────────
const createMut = useMutation({
mutationFn: () => createSkill(newName.trim(), newContent, newScope),
mutationFn: () => createSkill(newName.trim(), newContent, newScope, spaceId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['skills'] });
qc.invalidateQueries({ queryKey: skillsKey });
setSelected(newName.trim());
setNewMode(false);
setNewName('');
@@ -92,10 +101,10 @@ export function SkillsForm() {
const updateMut = useMutation({
mutationFn: ({ name, content, scope }: { name: string; content: string; scope: string }) =>
updateSkill(name, content, scope),
updateSkill(name, content, scope, spaceId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['skills'] });
qc.invalidateQueries({ queryKey: ['skill-detail', selected] });
qc.invalidateQueries({ queryKey: skillsKey });
qc.invalidateQueries({ queryKey: ['skill-detail', spaceId ?? null, selected] });
setEditMode(false);
setError(null);
},
@@ -103,9 +112,9 @@ export function SkillsForm() {
});
const deleteMut = useMutation({
mutationFn: ({ name, scope }: { name: string; scope: string }) => deleteSkill(name, scope),
mutationFn: ({ name, scope }: { name: string; scope: string }) => deleteSkill(name, scope, spaceId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['skills'] });
qc.invalidateQueries({ queryKey: skillsKey });
setSelected(null);
setEditMode(false);
setError(null);
@@ -114,9 +123,9 @@ export function SkillsForm() {
});
const installMut = useMutation({
mutationFn: () => installSkillFromUrl(installUrl.trim(), 'user'),
mutationFn: () => installSkillFromUrl(installUrl.trim(), 'user', undefined, spaceId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['skills'] });
qc.invalidateQueries({ queryKey: skillsKey });
setInstallUrl('');
setError(null);
},
@@ -188,23 +197,26 @@ export function SkillsForm() {
<div className="flex gap-4" style={{ minHeight: '500px' }}>
{/* ── Left panel: list ────────────────────────────────────────── */}
<div className="w-1/3 border border-hairline rounded-lg p-3 overflow-y-auto flex flex-col gap-2">
{/* Install from URL */}
<div className="flex gap-1.5">
<input
type="text"
placeholder="Install from URL..."
value={installUrl}
onChange={e => setInstallUrl(e.target.value)}
className="flex-1 min-w-0 px-2 py-1 text-xs border border-hairline rounded bg-canvas text-slate-700 placeholder:text-slate-400"
/>
<button
onClick={() => installMut.mutate()}
disabled={!installUrl.trim() || installMut.isPending}
className="px-2 py-1 text-xs font-semibold bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
>
{installMut.isPending ? '...' : 'Install'}
</button>
</div>
{/* Install from URL — per-user only; the Git install path is not yet
space-scoped, so hide it in space mode to avoid misleading users. */}
{!spaceId && (
<div className="flex gap-1.5">
<input
type="text"
placeholder="Install from URL..."
value={installUrl}
onChange={e => setInstallUrl(e.target.value)}
className="flex-1 min-w-0 px-2 py-1 text-xs border border-hairline rounded bg-canvas text-slate-700 placeholder:text-slate-400"
/>
<button
onClick={() => installMut.mutate()}
disabled={!installUrl.trim() || installMut.isPending}
className="px-2 py-1 text-xs font-semibold bg-accent text-accent-fg rounded hover:bg-accent-deep disabled:opacity-50 transition-colors whitespace-nowrap flex-shrink-0"
>
{installMut.isPending ? '...' : 'Install'}
</button>
</div>
)}
{/* Skill list */}
{skillsQuery.isLoading ? (
+2 -49
View File
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { FieldLabel, FieldInput } from './formUtils';
import { StringArrayEditor } from './StringArrayEditor';
import { NamespaceEditor } from './NamespaceEditor';
import type { SectionFormProps } from './types';
const TOOL_TABS = [
@@ -13,7 +12,6 @@ const TOOL_TABS = [
{ id: 'maps', label: 'Maps' },
{ id: 'amazon', label: 'Amazon' },
{ id: 'speech', label: 'Speech' },
{ id: 'knowledge', label: 'Knowledge (DKS) [LEGACY]' },
{ id: 'office', label: 'Office' },
{ id: 'uploads', label: 'Uploads' },
{ id: 'user-folder', label: 'User Folder' },
@@ -25,8 +23,8 @@ interface ToolsFormProps extends SectionFormProps {
/**
* Restrict the visible set of sub-tabs. When omitted, all tabs are shown.
* Used by the Settings sidebar Step 3 restructure to route sub-section ids
* (tools-web / tools-browser / tools-media / tools-external /
* tools-legacy-knowledge) into the same ToolsForm with a narrowed scope.
* (tools-web / tools-browser / tools-media / tools-external) into the same
* ToolsForm with a narrowed scope.
*/
visibleTabs?: readonly ToolTabId[];
}
@@ -215,51 +213,6 @@ export function ToolsForm({ config, onChange, visibleTabs }: ToolsFormProps) {
</div>
)}
{tab === 'knowledge' && (
<div className="space-y-5">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-800">Knowledge (DKS)</h3>
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-100 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300 border border-amber-300 dark:border-amber-500/30"
title={t('tools.knowledge.legacyBadgeTitle')}
>
LEGACY
</span>
</div>
<div
role="note"
className="rounded border border-amber-300 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15 px-3 py-2 text-xs text-amber-900 dark:text-amber-300"
>
{t('tools.knowledge.note')}{' '}
<a
href="/help"
className="underline text-amber-900 dark:text-amber-300 hover:text-amber-700 dark:hover:text-amber-300"
target="_blank"
rel="noopener noreferrer"
>
{t('tools.knowledge.mcpGuideLink')}
</a>
</div>
<div>
<FieldLabel>Knowledge Service URL</FieldLabel>
<FieldInput value={tools.knowledgeServiceUrl ?? ''} onChange={v => onChange('tools.knowledgeServiceUrl', v)}
placeholder="http://dks-server:8100" />
<HelpText>{t('tools.knowledge.serviceUrlHelp')}</HelpText>
</div>
<div>
<FieldLabel>Knowledge Namespaces</FieldLabel>
<NamespaceEditor
value={tools.knowledgeNamespaces ?? {}}
onChange={v => onChange('tools.knowledgeNamespaces', v)}
addDisabled
addDisabledReason={t('tools.knowledge.addDisabledReason')}
addDisabledHref="/help"
/>
<HelpText>{t('tools.knowledge.namespacesHelp')}</HelpText>
</div>
</div>
)}
{tab === 'user-folder' && (
<div className="space-y-5">
<div>
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { CONFIG_GROUPS, USER_SECTIONS } from './SettingsSidebar';
// Fix #4: Pets is a per-user personal preference, not a per-workspace setting.
// It must live under the "Preference" group and be reachable without admin
// (and in no-auth, where the group is shown because it is not adminOnly).
describe('Settings sidebar — Preference group', () => {
const preference = CONFIG_GROUPS.find(g => g.label === 'Preference');
it('has a Preference group', () => {
expect(preference).toBeDefined();
});
it('the Preference group is not admin-only (visible to every user incl. no-auth)', () => {
expect(preference && 'adminOnly' in preference ? preference.adminOnly : false).toBeFalsy();
});
it('Pets lives under the Preference group', () => {
const ids = preference?.sections.map(s => s.id) ?? [];
expect(ids).toContain('pets');
});
it('Pets is reachable by non-admin users (in USER_SECTIONS)', () => {
expect(USER_SECTIONS).toContain('pets');
});
it('the Pets nav uses the conventional settings-nav-pets testid (id-derived)', () => {
// SettingsSidebar renders data-testid={`settings-nav-${s.id}`}, so the
// section id alone determines the testid.
const pets = preference?.sections.find(s => s.id === 'pets');
expect(pets).toBeDefined();
expect(`settings-nav-${pets!.id}`).toBe('settings-nav-pets');
});
});
+289
View File
@@ -0,0 +1,289 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { fetchSpaceFileContent, fetchSpaceFiles, writeSpaceFile, deleteSpaceFiles, getSpaceFileRawUrl } from '../../api';
import {
resolveAppPath,
isWriteWithoutConfirm,
isAppBridgeRequest,
type AppBridgeResponse,
} from './app-bridge';
/**
* AppRunner — runs an agent-generated workspace app inside a sandboxed iframe
* and brokers its file I/O to the space file API via postMessage.
*
* SECURITY MODEL
* --------------
* - The app HTML is fetched (via the space content endpoint, on the user's
* session) and injected as the iframe `srcdoc`. The iframe is
* `sandbox="allow-scripts"` with **NO `allow-same-origin`**, so the document
* runs at an *opaque* origin: it cannot read the parent DOM, parent cookies,
* localStorage, or the user's session. (srcdoc + sandbox-without-same-origin
* is the cleanest opaque-origin setup — there is no real origin to inherit.)
* - The app gets NO credentials. All I/O goes through postMessage; the parent
* proxies each request using the USER's session (fetch with cookies). The app
* therefore can never exceed the user's permissions — membership /
* canEditInSpace / ensurePathWithin all still apply server-side.
* - Message handling trusts the *source window identity* (event.source ===
* iframe.contentWindow), NOT the origin string. A sandboxed opaque-origin
* iframe posts with origin "null", which is unforgeable to identify but
* shared by all opaque origins — so we never key on it.
* - Relative asset refs: srcdoc has no base URL, so bare relative `src`/`href`
* would not resolve. We rewrite *relative* asset URLs to absolute raw-file
* URLs under apps/{name}/ as a best-effort. V1's primary case is a single
* self-contained index.html (inline JS/CSS); multi-file is best-effort.
*/
interface AppRunnerProps {
spaceId: string;
/** 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;
}
interface ConfirmState {
path: string;
resolve: (approved: boolean) => void;
}
/**
* Rewrite bare relative src/href in the app HTML to absolute raw-file URLs
* rooted at the app folder. Leaves absolute (http(s):, //, /, data:, blob:,
* #, mailto:) URLs untouched. Best-effort — primary case is inline assets.
*/
/**
* 外部ネットワーク(データ持ち出し経路)を CSP で遮断する。アプリはサンドボックス
* (opaque origin) で動くが、`connect-src 'none'` が無いと fetch/XHR/sendBeacon/
* WebSocket で、ユーザーから渡されたワークスペースのファイル内容を外部へ送れてしまう。
* V1 のアプリは自己完結 HTML(インライン JS/CSS・data: 画像)が前提なので、外部
* リソースをすべて禁止し、ブリッジ(postMessage)だけを I/O 経路に残す。
*/
const APP_CSP =
"default-src 'none'; " +
"script-src 'unsafe-inline'; " +
"style-src 'unsafe-inline'; " +
"img-src data: blob:; " +
"font-src data:; " +
"media-src data: blob:; " +
"connect-src 'none'; " + // fetch/XHR/WebSocket/sendBeacon を遮断(exfil 対策の要)
"form-action 'none'; " + // フォーム外部 POST も遮断
"base-uri 'none'";
function injectCsp(html: string): string {
const meta = `<meta http-equiv="Content-Security-Policy" content="${APP_CSP}">`;
// <head> 直後に注入。<head> が無ければ <html> 直後、それも無ければ先頭。
if (/<head[^>]*>/i.test(html)) return html.replace(/<head[^>]*>/i, (m) => `${m}${meta}`);
if (/<html[^>]*>/i.test(html)) return html.replace(/<html[^>]*>/i, (m) => `${m}<head>${meta}</head>`);
return `${meta}${html}`;
}
function rewriteRelativeAssets(html: string, spaceId: string, appName: 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();
if (
u === '' ||
/^[a-z][a-z0-9+.-]*:/i.test(u) || // has a scheme (http:, data:, blob:, mailto:, javascript:)
u.startsWith('//') ||
u.startsWith('/') ||
u.startsWith('#')
) {
return m; // leave absolute / scheme / anchor as-is
}
// Resolve "./x" and "x" relative to the app folder; reject traversal.
let rel: string;
try {
rel = resolveAppPath(appName, `${appDir}/${u.replace(/^\.\//, '')}`);
} catch {
return m; // unsafe relative → leave untouched (will simply fail to load)
}
return `${attr}=${q}${getSpaceFileRawUrl(spaceId, rel)}${q}`;
});
}
export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerProps) {
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).
useEffect(() => {
let cancelled = false;
setSrcDoc(null);
setLoadError('');
(async () => {
try {
const html = await fetchSpaceFileContent(spaceId, entryPath);
if (cancelled) return;
setSrcDoc(injectCsp(rewriteRelativeAssets(html, spaceId, appName)));
} catch {
if (!cancelled) setLoadError('アプリの読み込みに失敗しました');
}
})();
return () => { cancelled = true; };
}, [spaceId, entryPath, appName]);
// Ask the user before a non-allowlisted write. Returns a promise that
// resolves true (approved) / false (denied).
const requestWriteConfirm = useCallback((path: string): Promise<boolean> => {
return new Promise<boolean>(resolve => {
setConfirm({ path, resolve });
});
}, []);
const handleConfirm = useCallback((approved: boolean) => {
setConfirm(prev => {
prev?.resolve(approved);
return null;
});
}, []);
// Core bridge: process one validated request → response payload.
const handleRequest = useCallback(async (req: { id: unknown; type: string; [k: string]: unknown }): Promise<AppBridgeResponse> => {
const { id } = req;
try {
switch (req.type) {
case 'readFile': {
const path = resolveAppPath(appName, req.path);
const content = await fetchSpaceFileContent(spaceId, path);
return { id, ok: true, data: { path, content } };
}
case 'listFiles': {
// dir is optional; '' lists the workspace root.
const dirRaw = req.dir;
let dir = '';
if (typeof dirRaw === 'string' && dirRaw.trim() !== '') {
dir = resolveAppPath(appName, dirRaw);
}
const r = await fetchSpaceFiles(spaceId, dir);
return { id, ok: true, data: { dir, entries: r.entries } };
}
case 'writeFile': {
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)) {
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 });
return { id, ok: true, data: res };
}
case 'deleteFile': {
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]);
return { id, ok: true, data: res };
}
default:
return { id, ok: false, error: `unknown request type: ${String(req.type)}` };
}
} catch (e) {
return { id, ok: false, error: e instanceof Error ? e.message : String(e) };
}
}, [spaceId, appName, requestWriteConfirm]);
// postMessage listener — only trusts messages from OUR iframe's window.
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const frameWin = iframeRef.current?.contentWindow;
// Trust source-window identity, not the (always "null") opaque origin.
if (!frameWin || event.source !== frameWin) return;
if (!isAppBridgeRequest(event.data)) return;
const req = event.data as { id: unknown; type: string; [k: string]: unknown };
void handleRequest(req).then(resp => {
// Reply to the opaque-origin iframe; targetOrigin must be '*' because
// the iframe has no real origin. The reply carries only data the user's
// own session already authorized, so '*' here leaks nothing extra.
frameWin.postMessage(resp, '*');
});
};
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [handleRequest]);
const title = useMemo(() => appName, [appName]);
return (
<div
data-testid="app-runner"
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-label={`ワークスペース・アプリ ${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="このアプリはワークスペースのファイルにアクセスします(あなたの権限の範囲内)"
>
</span>
<div className="flex-1" />
<button
type="button"
data-testid="app-runner-close"
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"
>
</button>
</div>
<div className="relative flex-1 min-h-0">
{loadError && (
<div className="flex h-full items-center justify-center text-sm text-red-600">{loadError}</div>
)}
{!loadError && srcDoc != null && (
<iframe
ref={iframeRef}
data-testid="app-runner-frame"
title={`app-${title}`}
// SECURITY: allow-scripts ONLY. No allow-same-origin → opaque origin.
sandbox="allow-scripts"
srcDoc={srcDoc}
className="h-full w-full border-0"
/>
)}
</div>
{confirm && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40">
<div
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="書き込み確認"
>
<p className="text-sm text-slate-700 dark:text-slate-200">
{title} <code className="rounded bg-surface px-1">{confirm.path}</code>
</p>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
data-testid="app-write-confirm-deny"
onClick={() => handleConfirm(false)}
className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface"
>
</button>
<button
type="button"
data-testid="app-write-confirm-allow"
onClick={() => handleConfirm(true)}
className="rounded bg-[var(--brand-primary)] px-3 py-1 text-sm text-white hover:opacity-90"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import {
clampSplitLeft,
splitFitsWidth,
SPLIT_MIN_LEFT,
SPLIT_MIN_RIGHT,
SPLIT_HANDLE_PX,
SPLIT_MIN_TOTAL,
} from './ChatDetailSplit';
describe('clampSplitLeft', () => {
// 広いコンテナ: 望む幅をそのまま使える範囲
const WIDE = 1400;
it('returns the desired width when within bounds', () => {
expect(clampSplitLeft(600, WIDE)).toBe(600);
});
it('clamps to the left minimum', () => {
expect(clampSplitLeft(50, WIDE)).toBe(SPLIT_MIN_LEFT);
expect(clampSplitLeft(-100, WIDE)).toBe(SPLIT_MIN_LEFT);
});
it('clamps to leave at least the right minimum (+ handle)', () => {
const maxLeft = WIDE - SPLIT_HANDLE_PX - SPLIT_MIN_RIGHT;
expect(clampSplitLeft(WIDE, WIDE)).toBe(maxLeft);
expect(clampSplitLeft(maxLeft + 200, WIDE)).toBe(maxLeft);
});
it('when the container is too narrow to satisfy both minimums, falls back to min-left', () => {
// total < minLeft + handle + minRight → maxLeft would dip below minLeft;
// Math.max keeps it at minLeft so the left pane is never below its floor.
const narrow = SPLIT_MIN_LEFT + SPLIT_MIN_RIGHT - 50; // intentionally too small
expect(clampSplitLeft(1000, narrow)).toBe(SPLIT_MIN_LEFT);
expect(clampSplitLeft(10, narrow)).toBe(SPLIT_MIN_LEFT);
});
it('honors custom min/handle arguments', () => {
expect(clampSplitLeft(100, 1000, 200, 200, 10)).toBe(200); // below custom minLeft
expect(clampSplitLeft(900, 1000, 200, 200, 10)).toBe(1000 - 10 - 200); // capped by custom minRight
});
});
describe('splitFitsWidth', () => {
it('is optimistic before measurement (width 0)', () => {
expect(splitFitsWidth(0)).toBe(true);
});
it('allows the split only when both minimums + handle fit', () => {
expect(splitFitsWidth(SPLIT_MIN_TOTAL)).toBe(true);
expect(splitFitsWidth(SPLIT_MIN_TOTAL + 200)).toBe(true);
expect(splitFitsWidth(SPLIT_MIN_TOTAL - 1)).toBe(false);
});
it('rejects a cramped mid-width container (~488px = rail+list eat the row)', () => {
// regression: 1024px window 256 rail 280 list ≈ 488px container
expect(splitFitsWidth(488)).toBe(false);
expect(SPLIT_MIN_TOTAL).toBe(SPLIT_MIN_LEFT + SPLIT_HANDLE_PX + SPLIT_MIN_RIGHT);
});
});
@@ -0,0 +1,190 @@
/**
* ChatDetailSplit — ワークスペース会話の「チャット + 詳細」2ペイン分割。
*
* 広い画面で、左にチャットを常時表示したまま、右に選択中の詳細タブ
* (ブラウザ / SSH / 概要 など)を並べる。タスクページの集中モードに近い操作感を、
* rail やダッシュボードを持ち込まずに実現する。
*
* 設計のキモ:
* - 左ペイン(チャット)は rightVisible の切り替えで remount しない。`right` を
* 出し入れするだけなので、詳細タブを開閉してもチャットの入力中テキストや
* スクロール位置が保たれる。
* - 幅はコンテナ ref + インライン style(px)で持ち、ドラッグ中は React 再 render
* を避けるため ref で直接 style を書き換える。確定時に localStorage へ保存。
* - 右ペイン非表示時は左を 100% にし、ハンドルも出さない。
*
* spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md は招待リンク用。
* 本コンポーネントはワークスペース統合(タスクページ廃止に向けた操作感寄せ)の一部。
*/
import { useCallback, useEffect, useRef, useState } from 'react';
export const SPLIT_MIN_LEFT = 320; // チャットの最小幅(px)
export const SPLIT_MIN_RIGHT = 360; // 詳細の最小幅(px)
export const SPLIT_HANDLE_PX = 6;
/** 2ペインを成立させるのに必要な最小コンテナ幅。これ未満では分割しない。 */
export const SPLIT_MIN_TOTAL = SPLIT_MIN_LEFT + SPLIT_HANDLE_PX + SPLIT_MIN_RIGHT;
/**
* 与えられたコンテナ幅で2ペイン分割が成立するか(両最小幅+ハンドルが収まるか)。
* 計測前(width=0)は楽観的に true を返し、ResizeObserver 確定後に絞り込む。
*/
export function splitFitsWidth(containerPx: number): boolean {
return containerPx === 0 || containerPx >= SPLIT_MIN_TOTAL;
}
/**
* 望ましい左ペイン幅を、最小幅制約の中にクランプする純関数(テスト対象)。
* total が両最小幅 + ハンドルを収めきれない狭さなら、左は最小幅に倒す。
*/
export function clampSplitLeft(
desiredLeftPx: number,
totalPx: number,
minLeft = SPLIT_MIN_LEFT,
minRight = SPLIT_MIN_RIGHT,
handle = SPLIT_HANDLE_PX,
): number {
const maxLeft = Math.max(minLeft, totalPx - handle - minRight);
return Math.max(minLeft, Math.min(maxLeft, desiredLeftPx));
}
function loadStoredWidth(key: string): number | null {
try {
const raw = localStorage.getItem(key);
const n = raw == null ? NaN : Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
} catch {
return null;
}
}
interface ChatDetailSplitProps {
left: React.ReactNode;
right: React.ReactNode;
/** false のとき右ペイン・ハンドルを出さず、左を全幅にする。 */
rightVisible: boolean;
/** 左ペイン幅を保存する localStorage キー。 */
storageKey: string;
}
export function ChatDetailSplit({ left, right, rightVisible, storageKey }: ChatDetailSplitProps) {
const containerRef = useRef<HTMLDivElement>(null);
const leftPaneRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef(false);
const lastLeftPxRef = useRef<number | null>(null);
// 初期幅: 保存値があれば px、無ければ null(= デフォルトの割合 fraction)。
const [leftPx, setLeftPx] = useState<number | null>(() => loadStoredWidth(storageKey));
// 現在のコンテナ幅。保存済み px を狭い画面で clamp し直すために計測する。
const [containerW, setContainerW] = useState(0);
// コンテナ幅を監視(ResizeObserver)。広い画面で保存した px を狭い画面で開いたとき
// 右ペインが最小幅を割らないよう、描画時に clampSplitLeft へ通すための材料。
useEffect(() => {
const el = containerRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 0;
if (w > 0) setContainerW(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// ドラッグ終了処理(pointerup / unmount / 右ペイン非表示化のいずれでも呼ぶ)。
// listener 削除だけだと draggingRef・body の cursor/userSelect が stuck するため、
// ここで必ず巻き戻す。persist=true のときだけ確定幅を保存する。
const endDrag = useCallback((persist: boolean) => {
if (!draggingRef.current) return;
draggingRef.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (persist && lastLeftPxRef.current != null) {
const px = lastLeftPxRef.current;
setLeftPx(px);
try { localStorage.setItem(storageKey, String(Math.round(px))); } catch { /* ignore */ }
}
}, [storageKey]);
// ドラッグ中の pointer 処理。ref-based で再 render せず style を直接書き換える。
useEffect(() => {
// 右ペインが無いときは drag できない。途中で非表示化されたら状態を巻き戻す。
if (!rightVisible) { endDrag(false); return; }
const onMove = (e: PointerEvent) => {
if (!draggingRef.current) return;
const el = containerRef.current;
const pane = leftPaneRef.current;
if (!el || !pane) return;
const rect = el.getBoundingClientRect();
const next = clampSplitLeft(e.clientX - rect.left, rect.width);
lastLeftPxRef.current = next;
pane.style.flex = `0 0 ${next}px`;
};
const onUp = () => endDrag(true);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', onUp);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
// unmount / rightVisible 変化が drag 中なら body スタイルを戻す(保存はしない)。
endDrag(false);
};
}, [rightVisible, endDrag]);
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
draggingRef.current = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, []);
const handleReset = useCallback(() => {
setLeftPx(null);
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
}, [storageKey]);
// 幅が足りないと2ペインにせず、選んだ詳細を単独表示にフォールバックする
// (チャットは mount を保ったまま hidden にして下書きを失わない)。
const canSplit = splitFitsWidth(containerW);
const showSplit = rightVisible && canSplit; // 真の2ペイン
const showRightOnly = rightVisible && !canSplit; // 狭い: 詳細を全幅、チャットは隠す
// 左ペインの flex。右非表示なら全幅、2ペイン時は px 指定(無ければ 42% 相当の割合)。
// 保存 px は現在のコンテナ幅で clamp し、狭い画面でも右の最小幅を死守する。
const appliedLeftPx = leftPx != null && containerW > 0 ? clampSplitLeft(leftPx, containerW) : leftPx;
const leftFlex = !rightVisible
? '1 1 100%'
: appliedLeftPx != null
? `0 0 ${appliedLeftPx}px`
: '0 0 42%';
return (
<div ref={containerRef} className="flex h-full min-h-0 w-full" data-testid="chat-detail-split">
<div
ref={leftPaneRef}
className="min-w-0 min-h-0 overflow-hidden"
// 狭くて詳細単独表示のときはチャットを hidden(mount 維持で下書き保持)。
style={showRightOnly ? { display: 'none' } : { flex: leftFlex }}
>
{left}
</div>
{showSplit && (
<div
role="separator"
aria-orientation="vertical"
aria-label="チャットと詳細の幅を調整"
data-testid="chat-detail-resize"
onPointerDown={handlePointerDown}
onDoubleClick={handleReset}
className="group flex shrink-0 cursor-col-resize items-stretch bg-transparent transition-colors hover:bg-slate-300/60"
style={{ width: SPLIT_HANDLE_PX, touchAction: 'none' }}
>
<div className="mx-auto w-px bg-hairline group-hover:bg-slate-500/40" />
</div>
)}
{rightVisible && (
<div className="min-w-0 min-h-0 flex-1 overflow-hidden">{right}</div>
)}
</div>
);
}
@@ -0,0 +1,149 @@
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useCreateSpace } from '../../hooks/useSpaces';
interface CreateSpaceDialogProps {
onClose: () => void;
onCreated?: (id: string) => void;
}
// DESIGN.md のブランド設定 UI に倣ったプリセット色。
const PRESET_COLORS = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#64748b'];
export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps) {
const createSpace = useCreateSpace();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [brandColor, setBrandColor] = useState<string>(PRESET_COLORS[0]);
const [error, setError] = useState('');
const submitting = createSpace.isPending;
const handleSubmit = async () => {
const trimmed = title.trim();
if (!trimmed) {
setError('ワークスペース名を入力してください。');
return;
}
setError('');
try {
const space = await createSpace.mutateAsync({
title: trimmed,
description: description.trim() || undefined,
brandColor: brandColor || null,
});
onCreated?.(space.id);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : 'ワークスペースを作成できませんでした。');
}
};
return (
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
<Dialog.Content
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
style={{ maxWidth: 'min(480px, 92vw)', maxHeight: '88dvh' }}
>
<div className="p-5">
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
aria-label="閉じる"
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">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</Dialog.Close>
</div>
<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>
<input
autoFocus
data-testid="space-title-input"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="例: ◯◯社 受託PJ"
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>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
placeholder="このワークスペースで扱う案件の概要"
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>
<div className="flex items-center gap-2">
{PRESET_COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => setBrandColor(c)}
aria-label={`${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="自由に選択">
<input
type="color"
value={brandColor}
onChange={e => setBrandColor(e.target.value)}
className="h-6 w-6 cursor-pointer rounded-md border border-hairline bg-none p-0"
/>
</label>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="mt-1 flex justify-end gap-2">
<Dialog.Close asChild>
<button
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"
>
</button>
</Dialog.Close>
<button
type="button"
data-testid="create-space-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 ? '作成中…' : '作成'}
</button>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
@@ -0,0 +1,315 @@
import { useMemo, useState } from 'react';
import { useQuery, useQueries } from '@tanstack/react-query';
import {
fetchCrossSpaceCalendarMonth,
fetchSpaceCalendarDay,
type CrossCalendarSpace,
} from '../../api';
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
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;
/** open a task thread (switches to the Tasks page). */
onOpenTask: (taskId: number) => void;
}
export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalendarProps) {
const tzOffset = localTzOffset();
const isMobile = useIsMobile();
const [month, setMonth] = useState(localMonth());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const monthQuery = useQuery({
queryKey: ['crossCalendarMonth', month, tzOffset],
queryFn: () => fetchCrossSpaceCalendarMonth(month, tzOffset),
});
const today = localToday();
const days = useMemo(() => monthGridDays(month), [month]);
const counts = monthQuery.data?.days ?? {};
const spaces = monthQuery.data?.spaces ?? [];
const spaceById = useMemo(() => {
const m = new Map<string, CrossCalendarSpace>();
for (const s of spaces) m.set(s.id, s);
return m;
}, [spaces]);
const monthLabel = (() => {
const [y, m] = month.split('-');
return `${y}${Number(m)}`;
})();
const grid = (
<div className="flex flex-col gap-2">
{/* month nav */}
<div className="flex items-center justify-between">
<button
type="button"
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="前の月"
>
<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>
<h2 data-testid="cross-cal-title" className="text-sm font-bold text-slate-800">{monthLabel}</h2>
<button
type="button"
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="次の月"
>
<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>
</div>
{/* weekday header */}
<div className="grid grid-cols-7 gap-1 text-center text-2xs font-semibold text-slate-400">
{WEEKDAYS.map((w, i) => (
<div key={w} className={i === 0 ? 'text-red-400' : i === 6 ? 'text-sky-400' : ''}>{w}</div>
))}
</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;
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 (
<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>
);
})}
</div>
{monthQuery.isError && <p className="text-xs text-red-600"></p>}
</div>
);
const panel = selectedDate ? (
<CrossDayPanel
date={selectedDate}
tzOffset={tzOffset}
counts={counts[selectedDate] ?? {}}
spaces={spaces}
onOpenSpace={onOpenSpace}
onOpenTask={onOpenTask}
onClose={() => setSelectedDate(null)}
/>
) : (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
</div>
);
// モバイル: 月の左右スワイプで月送り。日を選ぶと下にオーバーレイで日詳細を出す。
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>
);
}
// デスクトップ: 左に月グリッド、右に日詳細(split)。
return (
<div data-testid="cross-calendar" className="flex h-full min-h-0 gap-3 overflow-hidden">
<div className="w-full md:w-[440px] md:shrink-0 overflow-y-auto p-3">{grid}</div>
<div className="hidden md:block flex-1 min-w-0 overflow-y-auto border-l border-hairline">{panel}</div>
</div>
);
}
function CrossDayPanel({
date,
tzOffset,
counts,
spaces,
onOpenSpace,
onOpenTask,
onClose,
}: {
date: string;
tzOffset: number;
/** {[spaceId]: {taskCount,eventCount}} for this day (from the month aggregate). */
counts: Record<string, { taskCount: number; eventCount: number }>;
spaces: CrossCalendarSpace[];
onOpenSpace: (spaceId: string) => void;
onOpenTask: (taskId: number) => void;
onClose: () => void;
}) {
// 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;
}),
[spaces, counts],
);
const dayQueries = useQueries({
queries: activeSpaces.map(s => ({
queryKey: ['crossCalendarDay', s.id, date, tzOffset],
queryFn: () => fetchSpaceCalendarDay(s.id, date, tzOffset),
})),
});
return (
<div data-testid="cross-cal-day-panel" className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b border-hairline px-3 py-2">
<h3 className="text-sm font-bold text-slate-800">{date}</h3>
<button
type="button"
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="閉じる"
>
<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="flex-1 min-h-0 overflow-y-auto p-3 space-y-4">
{activeSpaces.length === 0 && (
<p className="text-xs text-slate-400"></p>
)}
{activeSpaces.map((sp, i) => {
const day = dayQueries[i]?.data;
return (
<section key={sp.id} data-testid={`cross-cal-space-${sp.id}`}>
<button
type="button"
onClick={() => onOpenSpace(sp.id)}
className="mb-1.5 flex w-full items-center gap-2 text-left"
>
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: sp.color }} />
<span className="min-w-0 flex-1 truncate text-xs font-bold text-slate-700 hover:text-slate-900">{sp.name}</span>
</button>
{/* タスク */}
{day && day.tasks.length > 0 && (
<ul className="space-y-1">
{day.tasks.map(t => (
<li key={t.id}>
<button
type="button"
data-testid={`cross-cal-task-${t.id}`}
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="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>
))}
</ul>
)}
{/* 予定 */}
{day && day.events.length > 0 && (
<ul className="mt-1 space-y-1">
{day.events.map(ev => (
<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 ?? '終日'}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
{ev.description && <div className="truncate text-[11px] text-slate-500">{ev.description}</div>}
</div>
</li>
))}
</ul>
)}
{day && day.tasks.length === 0 && day.events.length === 0 && (
<p className="text-xs text-slate-400"></p>
)}
</section>
);
})}
</div>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
/**
* JoinSpace — 招待リンク(/ui/invite/:token)の参加確認画面。
*
* App() が window.location.pathname で検出し、通常アプリの代わりに全画面表示する。
* プレビュー API の結果で 3 分岐:
* - unauthorized: ログイン導線(returnTo に現在の招待 URL を付ける)
* - invalid: リンク無効(期限切れ・取り消し・不明)
* - ok: 「{spaceTitle} に {role} として参加しますか?」→ 受諾でスペースへ遷移
*
* 認証は /api/local 全体の requireAuth が担保する。未ログインで preview は 401 を返す。
* spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
*/
import { useCallback, useEffect, useState } from 'react';
import {
fetchInvitePreview,
acceptSpaceInvite,
type InvitePreview,
type SpaceInviteRole,
} from '../../api';
const ROLE_LABEL: Record<SpaceInviteRole, string> = {
editor: '編集者',
viewer: '閲覧者',
};
type State =
| { kind: 'loading' }
| { kind: 'unauthorized' }
| { kind: 'invalid' }
| { kind: 'ok'; preview: InvitePreview };
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="h-dvh flex items-center justify-center bg-slate-50 px-4" data-testid="join-space">
<div className="w-[min(28rem,92vw)] rounded-xl border border-hairline bg-canvas p-6 shadow-sm text-center">
{children}
</div>
</div>
);
}
export function JoinSpace({ token }: { token: string }) {
const [state, setState] = useState<State>({ kind: 'loading' });
const [joining, setJoining] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
setState({ kind: 'loading' });
fetchInvitePreview(token)
.then((r) => {
if (cancelled) return;
if (r.status === 'ok') setState({ kind: 'ok', preview: r.preview });
else if (r.status === 'unauthorized') setState({ kind: 'unauthorized' });
else setState({ kind: 'invalid' });
})
.catch(() => { if (!cancelled) setState({ kind: 'invalid' }); });
return () => { cancelled = true; };
}, [token]);
const handleJoin = useCallback(async () => {
setJoining(true);
setError('');
try {
const { spaceId } = await acceptSpaceInvite(token);
// 参加先のワークスペースを開く。
window.location.href = `/ui/?page=spaces&space=${encodeURIComponent(spaceId)}`;
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setJoining(false);
}
}, [token]);
if (state.kind === 'loading') {
return (
<Shell>
<div className="mx-auto h-8 w-8 animate-spin rounded-full border-2 border-accent border-t-transparent" />
</Shell>
);
}
if (state.kind === 'unauthorized') {
const returnTo = encodeURIComponent(window.location.pathname);
return (
<Shell>
<h1 className="mb-2 text-base font-semibold text-slate-900"></h1>
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
</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"
>
</a>
</Shell>
);
}
if (state.kind === 'invalid') {
return (
<Shell>
<h1 className="mb-2 text-base font-semibold text-slate-900"></h1>
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
</p>
<a href="/ui/" className="text-[13px] text-slate-600 underline hover:text-slate-900">
</a>
</Shell>
);
}
// ok
return (
<Shell>
<h1 className="mb-1 text-base font-semibold text-slate-900"></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>
</p>
{error && <div className="mb-3 text-[13px] text-red-600">{error}</div>}
<div className="flex justify-center gap-2">
<a
href="/ui/"
className="rounded-md border border-hairline px-4 py-2 text-sm text-slate-700 hover:bg-surface"
>
</a>
<button
type="button"
data-testid="join-space-accept"
onClick={handleJoin}
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 ? '参加中…' : '参加する'}
</button>
</div>
</Shell>
);
}
+184
View File
@@ -0,0 +1,184 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchSpaceFiles, fetchSpaceFileContent } from '../../api';
import { deriveAppList, type WorkspaceApp } from './app-bridge';
import { AppRunner } from './AppRunner';
/**
* SpaceApps — the workspace "アプリ" tab.
*
* Lists the runnable workspace apps (subfolders under `apps/` that contain an
* `index.html`) and launches a selected one in the AppRunner (sandboxed iframe
* + postMessage bridge from Stage 1). Each app may carry an optional
* `apps/{name}/app.json` manifest ({title?, description?, entry?}); when present
* its title/description are shown, otherwise we fall back to the folder name +
* `index.html`.
*
* 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 }) {
const [appToRun, setAppToRun] = useState<WorkspaceApp | null>(null);
const appsQuery = useQuery({
queryKey: ['space-apps', spaceId],
queryFn: () => loadWorkspaceApps(spaceId),
staleTime: 10_000,
});
const apps = appsQuery.data ?? [];
return (
<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">
</p>
<button
type="button"
data-testid="space-apps-refresh"
onClick={() => void appsQuery.refetch()}
disabled={appsQuery.isFetching}
title="再読み込み"
aria-label="再読み込み"
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">
<path d="M2 8a6 6 0 0110.5-4M14 8a6 6 0 01-10.5 4" />
<path d="M12 2v3h-3M4 14v-3h3" />
</svg>
</button>
</div>
{appsQuery.isError && (
<p className="text-xs text-red-600"></p>
)}
{!appsQuery.isLoading && !appsQuery.isError && apps.length === 0 && (
<div
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="mt-1.5 leading-relaxed">
</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>
</p>
</div>
)}
{apps.length > 0 && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{apps.map(app => (
<div
key={app.name}
data-testid={`space-app-${app.name}`}
className="flex flex-col gap-2 rounded-lg border border-hairline bg-surface p-3"
>
<div className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-[var(--brand-primary)]/10 text-[var(--brand-primary)]" aria-hidden>
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<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" />
</svg>
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-800" title={app.title}>
{app.title}
</p>
{app.description ? (
<p className="mt-0.5 line-clamp-2 text-2xs text-slate-500">{app.description}</p>
) : (
<p className="mt-0.5 truncate font-mono text-2xs text-slate-400">apps/{app.name}/index.html</p>
)}
</div>
</div>
<button
type="button"
data-testid={`space-app-open-${app.name}`}
onClick={() => setAppToRun(app)}
className="mt-auto inline-flex h-8 items-center justify-center gap-1 rounded-md bg-accent px-3 text-xs font-bold text-accent-fg transition-colors hover:opacity-90"
>
<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>
</button>
</div>
))}
</div>
)}
{appToRun && (
<AppRunner
spaceId={spaceId}
appName={appToRun.name}
entryPath={appToRun.entryPath}
onClose={() => setAppToRun(null)}
/>
)}
</div>
);
}
/**
* Discover workspace apps for a space: list `apps/`, then for each subfolder
* check for an index.html and read its optional app.json. Folders without an
* index.html are skipped (not runnable). A missing `apps/` directory simply
* yields an empty list (the listing call 404s → caught → []).
*/
async function loadWorkspaceApps(spaceId: string): Promise<WorkspaceApp[]> {
// List the workspace root first (never 404s on a fresh workspace) and only
// descend into apps/ when that directory actually exists — listing a missing
// apps/ directly would 404 noisily on every fresh workspace.
let hasAppsDir = false;
try {
const root = await fetchSpaceFiles(spaceId, '');
hasAppsDir = root.entries.some(e => e.kind === 'directory' && e.name === 'apps');
} catch {
return [];
}
if (!hasAppsDir) return [];
let appDirs: string[];
try {
const appsRoot = await fetchSpaceFiles(spaceId, 'apps');
appDirs = appsRoot.entries.filter(e => e.kind === 'directory').map(e => e.name);
} catch {
return [];
}
if (appDirs.length === 0) return [];
// Fetch each app folder's listing in parallel to find index.html + app.json.
const perApp = await Promise.all(
appDirs.map(async name => {
try {
const dir = await fetchSpaceFiles(spaceId, `apps/${name}`);
const names = new Set(dir.entries.filter(e => e.kind !== 'directory').map(e => e.name));
const hasIndex = names.has('index.html');
let manifest: string | null = null;
if (hasIndex && names.has('app.json')) {
try {
manifest = await fetchSpaceFileContent(spaceId, `apps/${name}/app.json`);
} catch {
manifest = null; // tolerant: a bad/unreadable manifest falls back to defaults
}
}
return { name, hasIndex, manifest };
} catch {
return { name, hasIndex: false, manifest: null };
}
}),
);
const indexMap = new Map(perApp.map(p => [p.name, p]));
return deriveAppList(
appDirs,
name => indexMap.get(name)?.hasIndex ?? false,
name => indexMap.get(name)?.manifest ?? null,
);
}
@@ -0,0 +1,290 @@
/**
* SpaceBrowserPanel.tsx — スペース「設定 → ブラウザ」パネル
*
* そのスペースのブラウザセッションプロファイル・マクロ・録画をまとめて
* 管理する。すべて per-space スコープ:
* - セッション: GET/POST/DELETE /api/browser-sessions/profiles?spaceId=…
* - マクロ/録画: /api/users/me/folder/{list,file}?subdir=…&spaceId=…
*
* DEK は per-user のため、自分が作成していないセッション
* (decryptableByViewer===false) は一覧に出るが復号・利用できない。その行には
* 「作成者のみ利用可」を明示する。
*
* 管理操作(追加・削除)は canEditInSpace をバックエンドが強制する。UI は
* SpaceMembersPanel と同じ実シグナル(admin / 自分が owner 行)で、判定できる
* ときだけ管理コントロールを出す。判定できない場合でも 403 はトーストで処理。
*/
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
listBrowserSessionProfiles, deleteBrowserSessionProfile, testBrowserSessionProfile,
listFolderFiles, getFolderFile, deleteFolderFile,
fetchSpaceMembers,
type BrowserSessionProfile,
} from '../../api';
import { AddBrowserSessionDialog } from '../userfolder/AddBrowserSessionDialog';
import { FilePreview } from '../files/FilePreview';
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',
expired: 'bg-amber-100 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300',
revoked: 'bg-slate-200 text-slate-500',
error: 'bg-rose-100 dark:bg-rose-500/15 text-rose-700 dark:text-rose-300',
};
function StatusPill({ status }: { status: BrowserSessionProfile['status'] }) {
return (
<span className={`inline-flex items-center rounded px-2 py-0.5 text-2xs font-medium ${STATUS_CLASS[status]}`}>
{STATUS_LABEL[status]}
</span>
);
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const auth = useAuthState();
const qc = useQueryClient();
// 管理可否は SpaceMembersPanel と同じ実シグナルで判定する。判定できない
// ときも管理コントロールは出すが、403 は mutation onError でトースト処理。
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: profiles = [], isLoading: sessLoading } = useQuery({
queryKey: ['space-browser-sessions', spaceId],
queryFn: () => listBrowserSessionProfiles(spaceId),
});
const delSess = useMutation({
mutationFn: (id: number) => deleteBrowserSessionProfile(id, spaceId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }),
onError: (e) => showToast?.(`セッションの削除に失敗しました: ${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'),
});
const [adding, setAdding] = useState(false);
// ── マクロ / 録画 ───────────────────────────────────────────
const macros = useQuery({
queryKey: ['space-browser-macros', spaceId],
queryFn: () => listFolderFiles('browser-macros', spaceId),
});
const recordings = useQuery({
queryKey: ['space-browser-recordings', spaceId],
queryFn: () => listFolderFiles('recordings', spaceId),
});
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'),
});
const delRecording = useMutation({
mutationFn: (name: string) => deleteFolderFile('recordings', name, spaceId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-recordings', spaceId] }),
onError: (e) => showToast?.(`削除に失敗しました: ${errMsg(e)}`, 'error'),
});
// ファイル内容プレビュー(マクロ・録画共通)。
const [preview, setPreview] = useState<{ name: string; content: string } | null>(null);
async function openPreview(subdir: 'browser-macros' | 'recordings', name: string) {
try {
const content = await getFolderFile(subdir, name, spaceId);
setPreview({ name, content });
} catch (e) {
showToast?.(`内容の取得に失敗しました: ${errMsg(e)}`, 'error');
}
}
return (
<div className="h-full overflow-y-auto p-6" data-testid="space-browser-panel">
<div className="max-w-2xl space-y-8">
{/* ── セッション ── */}
<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>
{canManage && (
<button
type="button"
data-testid="space-browser-add-session"
onClick={() => setAdding(true)}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:bg-accent-deep"
>
</button>
)}
</div>
<p className="mb-3 text-xs text-slate-500">
</p>
{sessLoading && <div className="text-xs text-slate-500"></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>
)}
{profiles.map(p => {
const usable = p.decryptableByViewer !== false;
return (
<div key={p.id} data-testid={`space-browser-session-${p.id}`} className="flex items-center justify-between px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium text-slate-800">{p.label}</span>
<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">
</span>
)}
</div>
<div className="truncate text-2xs text-slate-500">{p.startUrl}</div>
{p.lastError && <div className="truncate text-2xs text-rose-600">{p.lastError}</div>}
{!usable && (
<div className="text-2xs text-slate-400">
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{usable && (
<button
type="button"
onClick={() => testSess.mutate(p.id)}
disabled={testSess.isPending}
className="rounded px-2 py-1 text-xs text-slate-700 hover:bg-surface hover:text-slate-900 disabled:opacity-50"
>
</button>
)}
{canManage && (
<button
type="button"
onClick={() => { if (confirm(`${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"
>
</button>
)}
</div>
</div>
);
})}
</div>
</section>
{/* ── マクロ ── */}
<FolderSection
title="ブラウザマクロ"
testid="space-browser-macros"
subdir="browser-macros"
query={macros}
emptyText="このワークスペースにはまだブラウザマクロがありません。"
hint="エージェントがブラウザ操作を記録すると、このワークスペースのマクロとして保存されます。"
canManage={canManage}
onView={(name) => openPreview('browser-macros', name)}
onDelete={(name) => { if (confirm(`${name}」を削除しますか?`)) delMacro.mutate(name); }}
/>
{/* ── 録画 ── */}
<FolderSection
title="録画"
testid="space-browser-recordings"
subdir="recordings"
query={recordings}
emptyText="このワークスペースにはまだ録画がありません。"
hint="ブラウザ操作の記録(録画)がこのワークスペースのフォルダに保存されます。"
canManage={canManage}
onView={(name) => openPreview('recordings', name)}
onDelete={(name) => { if (confirm(`${name}」を削除しますか?`)) delRecording.mutate(name); }}
/>
</div>
{adding && (
<AddBrowserSessionDialog spaceId={spaceId} onClose={() => setAdding(false)} />
)}
{preview && (
<FilePreview name={preview.name} content={preview.content} imageSrc="" onClose={() => setPreview(null)} />
)}
</div>
);
}
interface FolderSectionProps {
title: string;
testid: string;
subdir: 'browser-macros' | 'recordings';
query: { data?: { name: string; size: number; mtime: string }[]; isLoading: boolean };
emptyText: string;
hint: string;
canManage: boolean;
onView: (name: string) => void;
onDelete: (name: string) => void;
}
function FolderSection({ title, testid, query, emptyText, hint, canManage, onView, onDelete }: FolderSectionProps) {
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>}
<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>
)}
{files.map(f => (
<div key={f.name} className="flex items-center justify-between px-3 py-2">
<button
type="button"
onClick={() => onView(f.name)}
className="min-w-0 flex-1 text-left"
>
<div className="truncate text-[13px] font-medium text-slate-800">{f.name}</div>
<div className="text-2xs text-slate-400">{(f.size / 1024).toFixed(1)} KB · {new Date(f.mtime).toLocaleString()}</div>
</button>
{canManage && (
<button
type="button"
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"
>
</button>
)}
</div>
))}
</div>
</section>
);
}
+688
View File
@@ -0,0 +1,688 @@
import { useCallback, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceCalendarMonth,
fetchSpaceCalendarDay,
createCalendarEvent,
updateCalendarEvent,
deleteCalendarEvent,
fetchSpaceFileContent,
getSpaceFileRawUrl,
getSpaceTrustedHtmlUrl,
type CalendarEvent,
} from '../../api';
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
import { useIsMobile } from '../../hooks/useIsMobile';
import { FilePreview } 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 FILTERS_STORAGE_KEY = 'spaceCalendarFilters';
function loadFilters(): CalFilters {
try {
const raw = localStorage.getItem(FILTERS_STORAGE_KEY);
if (raw) {
const p = JSON.parse(raw) as Partial<CalFilters>;
return { tasks: p.tasks !== false, files: p.files !== false, events: p.events !== false };
}
} catch { /* ignore */ }
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). */
onOpenChat: (taskId: number) => void;
/** owner/admin can add/edit/delete events; viewers see read-only. */
canEdit: boolean;
}
export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarProps) {
const tzOffset = localTzOffset();
const isMobile = useIsMobile();
const [month, setMonth] = useState(localMonth());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
const [filters, setFilters] = useState<CalFilters>(loadFilters);
const toggleFilter = useCallback((key: keyof CalFilters) => {
setFilters((prev) => {
const next = { ...prev, [key]: !prev[key] };
try { localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(next)); } catch { /* ignore */ }
return next;
});
}, []);
const monthQuery = useQuery({
queryKey: ['spaceCalendarMonth', spaceId, month, tzOffset],
queryFn: () => fetchSpaceCalendarMonth(spaceId, month, tzOffset),
});
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 counts = monthQuery.data?.days ?? {};
const events = useMemo(() => monthQuery.data?.events ?? [], [monthQuery.data]);
const monthLabel = (() => {
const [y, m] = month.split('-');
return `${y}${Number(m)}`;
})();
const grid = (
<div className="flex flex-col gap-2">
{/* month nav */}
<div className="flex items-center justify-between">
<button
type="button"
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="前の月"
>
<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>
<h2 data-testid="space-cal-title" className="text-sm font-bold text-slate-800">{monthLabel}</h2>
<button
type="button"
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="次の月"
>
<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>
</div>
{/* 表示フィルター(タスク / 変更ファイル / 予定) */}
<div data-testid="space-cal-filters" className="flex flex-wrap items-center gap-1.5">
{FILTER_DEFS.map(f => {
const on = filters[f.key];
return (
<button
key={f.key}
type="button"
data-testid={`space-cal-filter-${f.key}`}
aria-pressed={on}
onClick={() => toggleFilter(f.key)}
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-2xs font-medium transition-colors ${
on
? 'border-[var(--brand-primary)] bg-[var(--brand-primary)]/10 text-slate-800 dark:text-slate-100'
: 'border-hairline bg-canvas text-slate-400 line-through'
}`}
>
<span aria-hidden>{f.icon}</span>{f.label}
</button>
);
})}
</div>
{/* weekday header */}
<div className="grid grid-cols-7 gap-1 text-center text-2xs font-semibold text-slate-400">
{WEEKDAYS.map((w, i) => (
<div key={w} className={i === 0 ? 'text-red-400' : i === 6 ? 'text-sky-400' : ''}>{w}</div>
))}
</div>
{/* day cells + 複数日の横棒(週ごと) */}
<div data-testid="space-cal-grid" className="flex flex-col gap-1">
{weeks.map((week, wi) => {
const bars = filters.events ? layoutWeekBars(week, events) : [];
const laneCount = bars.reduce((m, b) => Math.max(m, b.lane + 1), 0);
return (
<div key={wi} className="grid grid-cols-7 gap-1">
{week.map(d => {
const inMonth = d.slice(0, 7) === month;
const c = counts[d];
const isToday = d === today;
const isSelected = d === selectedDate;
return (
<button
key={d}
type="button"
data-testid={`space-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
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>
{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}`}>
💬{c.taskCount}
</span>
) : null}
</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 => (
<button
key={b.ev.id}
type="button"
data-testid={`space-cal-bar-${b.ev.id}`}
title={`${b.ev.title}${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 ${
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>}
</div>
);
const panel = selectedDate ? (
<DayPanel
spaceId={spaceId}
date={selectedDate}
tzOffset={tzOffset}
month={month}
canEdit={canEdit}
filters={filters}
onOpenChat={onOpenChat}
onClose={() => setSelectedDate(null)}
/>
) : (
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
</div>
);
// モバイル: 上に月グリッド(左右スワイプで月送り)、下に日詳細の上下 2 分割。
if (isMobile) {
return (
<div data-testid="space-calendar" className="flex h-full flex-col overflow-hidden">
<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="space-cal-mobile-detail" className="h-[45%] min-h-0 shrink-0 overflow-hidden border-t border-hairline">
{panel}
</div>
</div>
);
}
// デスクトップ: 左に月グリッド、右に日詳細(split)。
return (
<div data-testid="space-calendar" className="flex h-full min-h-0 gap-3 overflow-hidden">
<div className="w-full md:w-[440px] md:shrink-0 overflow-y-auto p-3">{grid}</div>
<div className="hidden md:block flex-1 min-w-0 overflow-y-auto border-l border-hairline">{panel}</div>
</div>
);
}
interface DayPanelPreview {
name: string;
content: string;
imageSrc: string;
markdownImageBaseUrl?: string;
trustedHtmlUrl?: string;
}
function DayPanel({
spaceId,
date,
tzOffset,
month,
canEdit,
filters,
onOpenChat,
onClose,
}: {
spaceId: string;
date: string;
tzOffset: number;
month: string;
canEdit: boolean;
filters: CalFilters;
onOpenChat: (taskId: number) => void;
onClose: () => void;
}) {
const qc = useQueryClient();
const dayQuery = useQuery({
queryKey: ['spaceCalendarDay', spaceId, date, tzOffset],
queryFn: () => fetchSpaceCalendarDay(spaceId, date, tzOffset),
});
const [preview, setPreview] = useState<DayPanelPreview | null>(null);
const [editing, setEditing] = useState<CalendarEvent | null>(null);
const [showAdd, setShowAdd] = useState(false);
const invalidate = useCallback(() => {
qc.invalidateQueries({ queryKey: ['spaceCalendarDay', spaceId, date, tzOffset] });
qc.invalidateQueries({ queryKey: ['spaceCalendarMonth', spaceId, month, tzOffset] });
}, [qc, spaceId, date, tzOffset, month]);
const handlePreview = useCallback(async (filePath: string, name: string) => {
try {
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
const imageSrc = getSpaceFileRawUrl(spaceId, filePath);
const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined;
setPreview({ name, content: '', imageSrc, trustedHtmlUrl });
return;
}
const content = await fetchSpaceFileContent(spaceId, filePath);
let markdownImageBaseUrl: string | undefined;
if (/\.(md|markdown)$/i.test(name)) {
const dir = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/') + 1) : '';
markdownImageBaseUrl = `/api/local/spaces/${spaceId}/files/raw?path=${dir}`;
}
setPreview({ name, content, imageSrc: '', markdownImageBaseUrl });
} catch {
/* preview failure is non-fatal; leave the list intact. */
}
}, [spaceId]);
const day = dayQuery.data;
return (
<div data-testid="space-cal-day-panel" className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b border-hairline px-3 py-2">
<h3 className="text-sm font-bold text-slate-800">{date}</h3>
<button
type="button"
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="閉じる"
>
<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="flex-1 min-h-0 overflow-y-auto p-3 space-y-4">
{/* タスク */}
{filters.tasks && (
<section>
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500"></h4>
{day && day.tasks.length > 0 ? (
<ul className="space-y-1">
{day.tasks.map(t => (
<li key={t.id}>
<button
type="button"
data-testid={`space-cal-task-${t.id}`}
onClick={() => onOpenChat(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="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>
))}
</ul>
) : (
<p className="text-xs text-slate-400"></p>
)}
</section>
)}
{/* 変更ファイル */}
{filters.files && (
<section>
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500"></h4>
{day && day.files.length > 0 ? (
<ul className="space-y-1">
{day.files.map(f => (
<li key={f.path}>
<button
type="button"
data-testid="space-cal-file"
data-name={f.name}
onClick={() => void handlePreview(f.path, f.name)}
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"
title={f.path}
>
<FileTypeIcon name={f.name} className="h-4 w-4 shrink-0 text-slate-400" />
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{f.name}</span>
<span className="shrink-0 text-[10px] text-slate-400">{fmtSize(f.size)}</span>
</button>
</li>
))}
</ul>
) : (
<p className="text-xs text-slate-400"></p>
)}
</section>
)}
{/* 予定 */}
{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>
{canEdit && !showAdd && !editing && (
<button
type="button"
data-testid="space-cal-add-event-btn"
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"
>
</button>
)}
</div>
{(showAdd || editing) && (
<EventForm
spaceId={spaceId}
defaultDate={date}
event={editing}
onCancel={() => { setShowAdd(false); setEditing(null); }}
onSaved={() => { setShowAdd(false); setEditing(null); invalidate(); }}
/>
)}
{day && day.events.length > 0 ? (
<ul className="mt-1 space-y-1">
{day.events.map(ev => (
<li
key={ev.id}
data-testid={`space-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 ?? '終日'}
</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>}
{ev.createdBy === 'agent' && <div className="text-[10px] text-slate-400">🤖 </div>}
</div>
{canEdit && (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
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="編集"
>
<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>
<button
type="button"
data-testid={`space-cal-event-delete-${ev.id}`}
onClick={async () => {
if (!window.confirm('この予定を削除しますか?')) 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="削除"
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" /></svg>
</button>
</div>
)}
</li>
))}
</ul>
) : (
!showAdd && <p className="text-xs text-slate-400"></p>
)}
</section>
)}
</div>
{preview && (
<FilePreview
name={preview.name}
content={preview.content}
imageSrc={preview.imageSrc}
markdownImageBaseUrl={preview.markdownImageBaseUrl}
trustedHtmlUrl={preview.trustedHtmlUrl}
onClose={() => setPreview(null)}
/>
)}
</div>
);
}
function EventForm({
spaceId,
defaultDate,
event,
onCancel,
onSaved,
}: {
spaceId: string;
defaultDate: string;
event: CalendarEvent | null;
onCancel: () => void;
onSaved: () => void;
}) {
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 [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; }
setSaving(true);
setError('');
try {
const payload = {
date,
// 終了日が開始日より後のときだけ複数日。それ以外は単日(null)に正規化。
endDate: endDate && endDate > date ? endDate : null,
time: time ? time : null,
title: title.trim(),
description: description ? description : null,
};
if (event) await updateCalendarEvent(spaceId, event.id, payload);
else await createCalendarEvent(spaceId, payload);
onSaved();
} catch (e) {
setError((e as Error)?.message ?? '保存に失敗しました。');
} finally {
setSaving(false);
}
}, [title, date, endDate, time, 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">
<input
type="text"
data-testid="space-cal-event-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="予定のタイトル"
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>
<input
type="date"
data-testid="space-cal-event-date"
value={date}
onChange={e => setDate(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"
/>
<input
type="time"
data-testid="space-cal-event-time"
value={time}
onChange={e => setTime(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"
/>
</div>
<div className="flex items-center gap-2">
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500"></label>
<input
type="date"
data-testid="space-cal-event-end-date"
value={endDate}
min={date}
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>
)}
</div>
<textarea
data-testid="space-cal-event-description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="メモ(任意)"
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"
/>
{error && <p className="text-2xs text-red-600">{error}</p>}
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={onCancel}
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"
>
</button>
<button
type="button"
data-testid="space-cal-event-save"
onClick={() => void submit()}
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 ? '更新' : '追加'}
</button>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,504 @@
/**
* SpaceMembersPanel.tsx — スペース「設定 → メンバー」パネル
*
* 共有スペース機能の管理 UI。現メンバー一覧(owner はバッジ付き・操作不可)と、
* 招待ピッカー(active ユーザーから選んでロール指定で追加)を提供する。
*
* 管理操作(招待・ロール変更・除去)は「管理者」または「このスペースの owner」
* のときだけ表示する。判定は以下の実シグナルで行う:
* - 認証無効(no-auth): synthetic local ユーザーが owner なので管理可。
* - 認証済み + role==='admin': 管理可。
* - 認証済み + 自分の id が owner メンバー行(isOwner)の userId と一致: 管理可。
* - それ以外: 読み取り専用。
* 401/403 は mutation の onError でトースト表示してフェイルセーフにする。
*
* no-auth では /api/users/pickable が空配列を返すため、招待ピッカーの代わりに
* 「認証を有効化するとスペースを共有できます」の案内を出す。
*/
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceMembers,
fetchPickableUsers,
addSpaceMember,
updateSpaceMemberRole,
removeSpaceMember,
fetchSpaceInvite,
createSpaceInvite,
revokeSpaceInvite,
type SpaceMember,
type SpaceMemberRole,
type SpaceInviteRole,
type PickableUser,
} from '../../api';
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);
}
function Avatar({ url, name }: { url: string | null; name: string | null }) {
const initial = (name ?? '?').trim().charAt(0).toUpperCase() || '?';
if (url) {
return <img src={url} alt="" className="h-8 w-8 shrink-0 rounded-full object-cover" />;
}
return (
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-2 text-xs font-semibold text-slate-600">
{initial}
</span>
);
}
export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const auth = useAuthState();
const qc = useQueryClient();
const { data: members, isLoading, isError, error } = useQuery({
queryKey: ['space-members', spaceId],
queryFn: () => fetchSpaceMembers(spaceId),
staleTime: 30_000,
});
// 管理可否(実シグナル)。owner メンバー行と自分の id を突き合わせる。
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 [picking, setPicking] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ['space-members', spaceId] });
const roleMut = useMutation({
mutationFn: ({ userId, role }: { userId: string; role: SpaceMemberRole }) =>
updateSpaceMemberRole(spaceId, userId, role),
onSuccess: invalidate,
onError: (e) => showToast?.(`ロールの変更に失敗しました: ${errMsg(e)}`, 'error'),
});
const removeMut = useMutation({
mutationFn: (userId: string) => removeSpaceMember(spaceId, userId),
onSuccess: invalidate,
onError: (e) => showToast?.(`メンバーの除去に失敗しました: ${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'),
});
const handleRemove = (m: SpaceMember) => {
const who = m.name ?? m.email ?? m.userId;
if (window.confirm(`${who} をこのワークスペースから除去しますか?`)) {
removeMut.mutate(m.userId);
}
};
return (
<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>
<p className="text-[13px] text-slate-500 leading-relaxed">
</p>
</div>
{isLoading && <div className="text-[13px] text-slate-400"></div>}
{isError && (
<div className="text-[13px] text-red-600">: {errMsg(error)}</div>
)}
{!isLoading && !isError && (
<div>
{(members ?? []).map(m => (
<div
key={m.userId}
data-testid={`space-member-${m.userId}`}
className="flex items-center gap-3 py-2.5 border-b border-hairline last:border-b-0"
>
<Avatar url={m.avatarUrl} name={m.name} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[13px] font-medium text-slate-900 truncate">
{m.name ?? m.email ?? m.userId}
</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">
</span>
)}
</div>
{m.email && <div className="text-2xs text-slate-500 truncate mt-0.5">{m.email}</div>}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{canManage && !m.isOwner ? (
<>
<select
data-testid={`space-member-role-${m.userId}`}
value={m.role}
disabled={roleMut.isPending}
onChange={(e) =>
roleMut.mutate({ userId: m.userId, role: 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>
</select>
<button
type="button"
data-testid={`space-member-remove-${m.userId}`}
onClick={() => handleRemove(m)}
disabled={removeMut.isPending}
className="text-2xs text-red-600 hover:text-red-800 dark:hover:text-red-300 underline disabled:opacity-50"
>
</button>
</>
) : (
<span
data-testid={`space-member-role-${m.userId}`}
className="text-2xs text-slate-500"
>
{ROLE_LABEL[m.role]}
</span>
)}
</div>
</div>
))}
</div>
)}
{/* 招待リンク(再利用トークン)。組織に依存せず、リンクを渡した相手が参加できる。 */}
{canManage && auth.mode !== 'disabled' && (
<InviteLinkSection spaceId={spaceId} showToast={showToast} />
)}
{/* 招待(ピッカー) */}
{canManage && (
<div>
{picking ? (
<InvitePicker
spaceId={spaceId}
existing={members ?? []}
isPending={addMut.isPending}
onCancel={() => setPicking(false)}
onAdd={(userId, role) => addMut.mutate({ userId, role })}
/>
) : (
<button
type="button"
data-testid="space-member-invite"
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"
>
</button>
)}
</div>
)}
</div>
</div>
);
}
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 },
];
/**
* 招待リンク・セクション。スペースごとに有効リンクは1本。役割・有効期限を選んで
* 生成し、コピー / 再生成 / 無効化できる。リンクを知る相手だけが参加できるため、
* 組織(pickable の絞り込み)に依存しない招待経路になる。
*/
function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const qc = useQueryClient();
const [role, setRole] = useState<SpaceInviteRole>('viewer');
const [expiryIdx, setExpiryIdx] = useState(0);
const [copied, setCopied] = useState(false);
const { data: invite, isLoading } = useQuery({
queryKey: ['space-invite', spaceId],
queryFn: () => fetchSpaceInvite(spaceId),
staleTime: 30_000,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['space-invite', spaceId] });
const createMut = useMutation({
mutationFn: () => createSpaceInvite(spaceId, { role, expiresInDays: EXPIRY_OPTIONS[expiryIdx].days }),
onSuccess: invalidate,
onError: (e) => showToast?.(`招待リンクの作成に失敗しました: ${errMsg(e)}`, 'error'),
});
const revokeMut = useMutation({
mutationFn: () => revokeSpaceInvite(spaceId),
onSuccess: invalidate,
onError: (e) => showToast?.(`招待リンクの無効化に失敗しました: ${errMsg(e)}`, 'error'),
});
const absoluteUrl = invite ? `${window.location.origin}${invite.url}` : '';
const active = !!invite && invite.valid;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(absoluteUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
showToast?.('コピーに失敗しました。手動で選択してください。', '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>
<p className="text-2xs text-slate-500 leading-relaxed mt-0.5">
</p>
</div>
{isLoading ? (
<div className="text-[13px] text-slate-400"></div>
) : active ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
readOnly
data-testid="space-invite-url"
value={absoluteUrl}
onFocus={(e) => e.currentTarget.select()}
className="h-8 flex-1 rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-700 focus:border-accent focus:outline-none"
/>
<button
type="button"
data-testid="space-invite-copy"
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 ? 'コピー済' : 'コピー'}
</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()}` : ' ・ 無期限'}
</span>
<div className="flex items-center gap-2">
<button
type="button"
data-testid="space-invite-regenerate"
onClick={() => createMut.mutate()}
disabled={createMut.isPending}
className="text-slate-600 underline hover:text-slate-900 disabled:opacity-50"
>
</button>
<button
type="button"
data-testid="space-invite-revoke"
onClick={() => revokeMut.mutate()}
disabled={revokeMut.isPending}
className="text-red-600 underline hover:text-red-800 disabled:opacity-50"
>
</button>
</div>
</div>
</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<select
data-testid="space-invite-role"
value={role}
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>
</select>
<select
data-testid="space-invite-expiry"
value={expiryIdx}
onChange={(e) => setExpiryIdx(Number(e.target.value))}
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>
))}
</select>
<button
type="button"
data-testid="space-invite-create"
onClick={() => createMut.mutate()}
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 ? '作成中…' : '招待リンクを作成'}
</button>
</div>
</div>
)}
</div>
);
}
function InvitePicker({
spaceId,
existing,
isPending,
onCancel,
onAdd,
}: {
spaceId: string;
existing: SpaceMember[];
isPending: boolean;
onCancel: () => void;
onAdd: (userId: string, role: SpaceMemberRole) => void;
}) {
const { data: users, isLoading } = useQuery({
queryKey: ['pickable-users', spaceId],
queryFn: fetchPickableUsers,
staleTime: 30_000,
});
const [search, setSearch] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [role, setRole] = useState<SpaceMemberRole>('editor');
// 既にメンバー / owner のユーザーは候補から除外。
const memberIds = useMemo(() => new Set(existing.map(m => m.userId)), [existing]);
const candidates = useMemo(() => {
const q = search.trim().toLowerCase();
return (users ?? [])
.filter(u => !memberIds.has(u.id))
.filter(u => {
if (!q) return true;
return (
(u.name ?? '').toLowerCase().includes(q) ||
(u.email ?? '').toLowerCase().includes(q)
);
});
}, [users, memberIds, search]);
// no-auth: pickable が空 = 実ユーザー不在。共有できない案内を出す。
if (!isLoading && (users ?? []).length === 0) {
return (
<div
data-testid="space-member-picker"
className="rounded-md border border-hairline bg-surface/50 p-4 text-[13px] text-slate-500"
>
<div className="mt-3">
<button
type="button"
onClick={onCancel}
className="text-xs text-slate-600 hover:text-slate-800 underline"
>
</button>
</div>
</div>
);
}
// 候補ゼロの理由を区別する: 全員追加済みか、そもそも同じ組織にメンバーがいないか。
const allAlreadyAdded = (users ?? []).length > 0 && candidates.length === 0 && search.trim() === '';
return (
<div
data-testid="space-member-picker"
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">
</p>
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="名前・メールで検索"
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>
) : candidates.length === 0 ? (
<div className="text-[13px] text-slate-400">
{allAlreadyAdded
? '同じ組織のメンバーは全員このワークスペースに追加済みです。'
: '追加できるユーザーがいません。同じ組織のメンバーだけが候補に表示されます。'}
</div>
) : (
<div className="max-h-56 overflow-y-auto">
{candidates.map((u: PickableUser) => {
const active = selectedId === u.id;
return (
<button
key={u.id}
type="button"
onClick={() => setSelectedId(u.id)}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors ${
active ? 'bg-accent-soft' : 'hover:bg-surface'
}`}
>
<Avatar url={u.avatarUrl} name={u.name} />
<div className="min-w-0 flex-1">
<div className="text-[13px] text-slate-900 truncate">{u.name ?? u.email ?? u.id}</div>
{u.email && <div className="text-2xs text-slate-500 truncate">{u.email}</div>}
</div>
</button>
);
})}
</div>
)}
<div className="flex items-center gap-2">
<select
value={role}
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>
</select>
<button
type="button"
disabled={!selectedId || isPending}
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 ? '追加中…' : '追加'}
</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"
>
</button>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useState } from 'react';
import { useSpaces } from '../../hooks/useSpaces';
import { sortSpacesForRail } from '../../lib/spaceSort';
import type { Space } from '../../api';
import { CreateSpaceDialog } from './CreateSpaceDialog';
interface SpaceRailProps {
selectedId?: string;
onSelect: (id: string) => void;
}
const VIS_LABEL: Record<Space['visibility'], string> = {
private: 'private',
org: 'org',
public: 'public',
};
export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
const { data: spaces, isLoading, isError } = useSpaces();
const [showCreate, setShowCreate] = useState(false);
const sorted = useMemo(() => sortSpacesForRail(spaces ?? []), [spaces]);
const personal = sorted.filter(s => s.kind === 'personal');
const cases = sorted.filter(s => s.kind !== 'personal');
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>
<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"
>
</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>}
{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} />
))}
{!isLoading && !isError && sorted.length === 0 && (
<p className="px-1 py-2 text-xs text-slate-500"> </p>
)}
</div>
{showCreate && (
<CreateSpaceDialog
onClose={() => setShowCreate(false)}
onCreated={(id) => {
setShowCreate(false);
onSelect(id);
}}
/>
)}
</div>
);
}
function SpaceRow({
space,
active,
onSelect,
}: {
space: Space;
active: boolean;
onSelect: (id: string) => void;
}) {
const dot = space.brandColor ?? 'var(--brand-primary)';
return (
<button
type="button"
data-testid="space-row"
data-space-kind={space.kind}
data-space-id={space.id}
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
? 'border-hairline bg-[var(--brand-primary-soft)]'
: 'border-transparent hover:bg-surface-2'
}`}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ background: dot }}
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>
</button>
);
}
+212
View File
@@ -0,0 +1,212 @@
/**
* SpaceSettings.tsx — スペース詳細の「設定」タブ
*
* ユーザーフォルダ相当の設定を、そのスペースのフォルダ
* (`data/spaces/{id}/…`) に対して扱う。左に sub-nav、右に対応パネル。
*
* - AGENTS.md / メモリ / Pieces / スキル: ファイルベース。spaceId を
* 各 API に渡してスペースフォルダを操作する(個人スペースは leafId=
* ユーザーIDなので User Folder と同一実体になる)。
* - MCP / SSH: DB ベースだが per-space 化済み(spec §11)。spaceId を
* 渡して、そのスペース専用のサーバー/接続として一覧・登録する。
*/
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { AgentsMdPanel } from '../userfolder/AgentsMdPanel';
import { MemoryPanel } from '../userfolder/MemoryPanel';
import { SkillsPanel } from '../userfolder/SkillsPanel';
import { McpPanel } from '../userfolder/McpPanel';
import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
import { SpaceMembersPanel } from './SpaceMembersPanel';
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
import { PieceEditor } from '../settings/PieceEditor';
import { usePieceList } from '../../hooks/usePieces';
import { splitPieces } from '../../lib/splitPieces';
import { createPiece, type PieceDef, type PieceSummary } from '../../api';
import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members';
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' },
];
export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
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="ワークスペース設定"
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 => {
const active = section === s.id;
return (
<button
key={s.id}
type="button"
data-testid={s.testid}
onClick={() => setSection(s.id)}
className={`shrink-0 whitespace-nowrap rounded-md px-3 py-1.5 text-left text-sm font-medium transition-colors md:w-full ${
active
? 'bg-accent-soft text-accent font-semibold'
: 'text-slate-600 hover:bg-surface hover:text-slate-900'
}`}
>
{s.label}
</button>
);
})}
</nav>
{/* 右ペイン */}
<div className="min-h-0 flex-1 overflow-y-auto pt-3 md:pt-0">
{section === 'agents' && <AgentsMdPanel spaceId={spaceId} />}
{section === 'memory' && <MemoryPanel spaceId={spaceId} />}
{section === 'pieces' && <SpacePiecesPanel spaceId={spaceId} showToast={showToast} />}
{section === 'skills' && <SkillsPanel spaceId={spaceId} />}
{section === 'mcp' && <McpPanel spaceId={spaceId} showToast={showToast} />}
{section === 'ssh' && <SshConnectionsPanel spaceId={spaceId} showToast={showToast} />}
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
</div>
</div>
);
}
/**
* スペースフォルダの Pieces 一覧 + 編集。`PiecesPage` は URL state 結合が深い
* ため、ここでは軽量な一覧(splitPieces で Default/Custom 分け)+ 重い
* `PieceEditor` の再利用で構成する。選択はローカル state。
*/
function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const auth = useAuthState();
const isAdmin = auth.mode === 'authenticated' && auth.user.role === 'admin';
const qc = useQueryClient();
const { data: pieces } = usePieceList(spaceId);
const [selected, setSelected] = useState<{ name: string; source: 'builtin' | 'user-custom' | 'global-custom' } | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
const { defaults, customs } = splitPieces(pieces ?? []);
const handleCreate = async () => {
const name = newName.trim();
if (!name || creating) return;
const defaultPiece: PieceDef = {
name,
description: '',
max_movements: 25,
initial_movement: 'execute',
movements: [{
name: 'execute',
edit: true,
persona: 'worker',
instruction: '',
allowed_tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
default_next: 'COMPLETE',
rules: [{ condition: '完了', next: 'COMPLETE' }],
}],
};
try {
setCreating(true);
const { source } = await createPiece(defaultPiece, spaceId);
await qc.invalidateQueries({ queryKey: ['pieces', spaceId] });
setIsCreating(false);
setNewName('');
setSelected({ name, source });
} catch (e) {
const msg = `Piece の作成に失敗しました: ${e instanceof Error ? e.message : String(e)}`;
if (showToast) showToast(msg, 'error');
else console.error(msg);
} finally {
setCreating(false);
}
};
const renderRow = (p: PieceSummary, isBuiltin: boolean) => {
const src = (p.source ?? (isBuiltin ? 'builtin' : 'user-custom')) as 'builtin' | 'user-custom' | 'global-custom';
const active = selected?.name === p.name && selected?.source === src;
return (
<button
key={`${src}-${p.name}`}
type="button"
onClick={() => setSelected({ name: p.name, source: src })}
className={`w-full truncate rounded px-2 py-1 text-left text-xs transition-colors ${
active ? 'bg-accent-soft text-accent font-semibold' : 'text-slate-700 hover:bg-surface'
}`}
>
{p.name}
</button>
);
};
return (
<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>}
{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>
<button
type="button"
onClick={() => setIsCreating(true)}
title="新しい Piece"
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"
>
+
</button>
</div>
{isCreating && (
<div className="mb-1 px-2">
<input
autoFocus
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newName.trim()) void handleCreate();
if (e.key === 'Escape') { setIsCreating(false); setNewName(''); }
}}
disabled={creating}
placeholder="piece-name"
className="h-7 w-full rounded-md border border-hairline px-2 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
/>
</div>
)}
{customs.length === 0 && !isCreating && <div className="px-2 text-xs text-slate-400">()</div>}
{customs.map(p => renderRow(p, false))}
</div>
{/* 右: エディタ */}
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{selected ? (
<PieceEditor
name={selected.name}
source={selected.source}
isAdmin={isAdmin}
spaceId={spaceId}
onDeleted={() => setSelected(null)}
/>
) : (
<div className="text-sm text-slate-400"> Piece </div>
)}
</div>
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { SpaceRail } from './SpaceRail';
import { SpaceDetail } from './SpaceDetail';
import { WorkerStatusWidget } from '../dashboard/WorkerStatusWidget';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
import type { CreateLocalTaskInput } from '../../api';
interface SpacesPageProps {
spaceId?: string;
spaceTaskId?: number;
onSelectSpace: (id: string | undefined) => void;
onSelectSpaceTask: (id: number) => void;
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
onOpenTask: (id: number) => void;
}
// レール幅の許容範囲。狭すぎると一覧が読めず、広すぎると詳細を圧迫するため上下限でクランプ。
const RAIL_MIN_PX = 180;
const RAIL_MAX_PX = 460;
const RAIL_DEFAULT_PX = 256;
function clampRailWidth(px: number): number {
if (Number.isNaN(px)) return RAIL_DEFAULT_PX;
return Math.max(RAIL_MIN_PX, Math.min(RAIL_MAX_PX, Math.round(px)));
}
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpacesPageProps) {
const isMobile = useIsMobile();
const [railWidth, setRailWidth] = useLocalStorageState<number>('maestro.spaceRailWidth', RAIL_DEFAULT_PX);
const [collapsed, setCollapsed] = useLocalStorageState<boolean>('maestro.spaceRailCollapsed', false);
// 広幅でのみ可変幅・折りたたみを適用。狭幅は従来どおり全幅一覧 + spaceId で hidden 切替。
const width = clampRailWidth(railWidth);
const desktopCollapsed = !isMobile && collapsed;
// レール本体の表示クラス。狭幅は w-full、広幅は inline style で幅を当てるため幅クラスを付けない。
const railVisibilityClass = spaceId ? 'hidden md:flex' : 'flex';
return (
<div className="flex h-full min-h-0 overflow-hidden">
{/* レール: 狭幅では全幅一覧。スペースを開いている狭幅では隠す(詳細が一覧を置き換える)。
広幅はユーザー可変幅(ドラッグ)+折りたたみ可能。
縦は flex-col で、スペース一覧が残り高さを占め、ワーカー/GPU 状況が下部フッターに常駐する。 */}
{desktopCollapsed ? (
// 折りたたみ時: 細い再オープン用ストリップだけを残す。詳細が空いた幅を受け取る。
<button
type="button"
data-testid="space-rail-collapse"
onClick={() => setCollapsed(false)}
aria-expanded={false}
title="ワークスペース一覧を開く"
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>
</button>
) : (
<div
className={`w-full md:w-auto md:shrink-0 relative flex flex-col min-h-0 border-r border-hairline bg-surface ${railVisibilityClass}`}
style={isMobile ? undefined : { width }}
>
{/* 広幅のみ: 折りたたみボタン(ヘッダー右上の薄いツールバー)。 */}
<div className="hidden md:flex shrink-0 items-center justify-end border-b border-hairline px-2 py-1">
<button
type="button"
data-testid="space-rail-collapse"
onClick={() => setCollapsed(true)}
aria-expanded
title="ワークスペース一覧を折りたたむ"
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">
<path d="M10 4l-4 4 4 4" />
</svg>
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<SpaceRail selectedId={spaceId} onSelect={onSelectSpace} />
</div>
<WorkerStatusFooter />
{/* 広幅のみ: 右端のドラッグハンドルで幅を調整。狭幅では非表示。 */}
{!isMobile && (
<RailResizeHandle
width={width}
onResize={px => setRailWidth(clampRailWidth(px))}
/>
)}
</div>
)}
{/* 詳細: 狭幅ではスペース選択時のみ全幅表示。未選択の狭幅では隠す。広幅は常時表示。 */}
<div className={`min-w-0 flex-1 flex-col ${spaceId ? 'flex' : 'hidden md:flex'}`}>
{/* 狭幅専用: スペース一覧へ戻る。広幅ではレールが常時見えるので不要。
狭幅でチャットを開いているときは隠す(チャット側の「一覧へ」が直近の戻る導線で、
スペース一覧へはチャット一覧から辿れるため、戻るボタンの重複を避ける)。 */}
{spaceId && (
<button
type="button"
data-testid="space-back-to-list"
onClick={() => onSelectSpace(undefined)}
className={`${spaceTaskId != null ? 'hidden' : 'md:hidden flex'} w-full items-center gap-1 border-b border-hairline bg-surface px-3 py-2 text-xs font-semibold text-slate-600 hover:text-slate-900`}
>
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 4l-4 4 4 4" />
</svg>
</button>
)}
<div className="min-h-0 flex-1">
<SpaceDetail
spaceId={spaceId}
spaceTaskId={spaceTaskId}
onSelectSpace={onSelectSpace}
onSelectSpaceTask={onSelectSpaceTask}
onCreateTask={onCreateTask}
onOpenTask={onOpenTask}
/>
</div>
</div>
</div>
);
}
// レール右端のドラッグハンドル。mousedown→mousemove→mouseup でレール幅を更新する。
// 親が clamp するので、ここでは絶対 X 座標からレール左端基準の幅を計算するだけ。
// latest-ref パターンで drag 中に listener を貼り直さない。
function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: number) => void }) {
const onResizeRef = useRef(onResize);
onResizeRef.current = onResize;
const draggingRef = useRef(false);
const handleRef = useRef<HTMLDivElement>(null);
const railLeftRef = useRef(0);
const [active, setActive] = useState(false);
useEffect(() => {
const handleMove = (e: PointerEvent) => {
if (!draggingRef.current) return;
onResizeRef.current(e.clientX - railLeftRef.current);
};
const handleUp = () => {
if (!draggingRef.current) return;
draggingRef.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
setActive(false);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
window.addEventListener('pointercancel', handleUp);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
window.removeEventListener('pointercancel', handleUp);
};
}, []);
const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
// レール左端の絶対 X を記録(ハンドル位置 - 現在幅)。
const rect = handleRef.current?.getBoundingClientRect();
railLeftRef.current = rect ? rect.right - width : e.clientX - width;
draggingRef.current = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
setActive(true);
}, [width]);
return (
<div
ref={handleRef}
role="separator"
aria-orientation="vertical"
aria-label="ワークスペース一覧の幅を調整"
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'}`}
style={{ touchAction: 'none' }}
>
<div className="ml-auto h-full w-px bg-hairline" />
</div>
);
}
// レール下部に常駐する折りたたみ式のワーカー/GPU 状況パネル。Tasks ページと同じ
// WorkerStatusWidget を使い、空きスロット(=投入余地)を一目で確認できるようにする。
// 既定は折りたたみで、レール本体の高さを圧迫しない。
function WorkerStatusFooter() {
const [open, setOpen] = useState(false);
return (
<div data-testid="space-worker-status" className="shrink-0 border-t border-hairline bg-surface">
<button
type="button"
data-testid="space-worker-status-toggle"
onClick={() => setOpen(v => !v)}
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>
<svg
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 6l4 4 4-4" />
</svg>
</button>
{open && (
<div data-testid="space-worker-status-panel" className="max-h-48 overflow-auto px-2 pb-2">
<WorkerStatusWidget />
</div>
)}
</div>
);
}
+259
View File
@@ -0,0 +1,259 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
resolveAppPath,
isWriteWithoutConfirm,
isAppBridgeRequest,
detectAppEntry,
parseAppManifest,
resolveAppEntry,
deriveAppList,
} from './app-bridge';
describe('resolveAppPath (client-side path guard)', () => {
it('accepts a clean relative path and returns it normalized', () => {
expect(resolveAppPath('foo', 'output/report.txt')).toBe('output/report.txt');
expect(resolveAppPath('foo', 'apps/foo/data/state.json')).toBe('apps/foo/data/state.json');
});
it('collapses ./ and redundant slashes', () => {
expect(resolveAppPath('foo', './output/./a//b.txt')).toBe('output/a/b.txt');
});
it('rejects absolute paths', () => {
expect(() => resolveAppPath('foo', '/etc/passwd')).toThrow();
});
it('rejects ".." traversal in any segment', () => {
expect(() => resolveAppPath('foo', '../secret')).toThrow();
expect(() => resolveAppPath('foo', 'output/../../escape')).toThrow();
expect(() => resolveAppPath('foo', 'a/b/../../../c')).toThrow();
});
it('rejects backslashes and drive-letter paths', () => {
expect(() => resolveAppPath('foo', 'output\\evil')).toThrow();
expect(() => resolveAppPath('foo', 'C:/Windows/system32')).toThrow();
});
it('rejects empty / non-string / root-only', () => {
expect(() => resolveAppPath('foo', '')).toThrow();
expect(() => resolveAppPath('foo', ' ')).toThrow();
expect(() => resolveAppPath('foo', 42 as unknown as string)).toThrow();
expect(() => resolveAppPath('foo', '/')).toThrow();
expect(() => resolveAppPath('foo', './.')).toThrow();
});
it('rejects control characters / NUL', () => {
expect(() => resolveAppPath('foo', 'output/a\x00b')).toThrow();
});
});
describe('isWriteWithoutConfirm (silent-write policy)', () => {
it('allows under output/ without confirm', () => {
expect(isWriteWithoutConfirm('foo', 'output')).toBe(true);
expect(isWriteWithoutConfirm('foo', 'output/report.html')).toBe(true);
expect(isWriteWithoutConfirm('foo', 'output/sub/x.txt')).toBe(true);
});
it('allows under apps/{appName}/data/ without confirm', () => {
expect(isWriteWithoutConfirm('foo', 'apps/foo/data')).toBe(true);
expect(isWriteWithoutConfirm('foo', 'apps/foo/data/state.json')).toBe(true);
});
it('requires confirm for other paths', () => {
expect(isWriteWithoutConfirm('foo', 'notes.txt')).toBe(false);
expect(isWriteWithoutConfirm('foo', 'input/x.txt')).toBe(false);
// another app's data dir is NOT silently writable by this app
expect(isWriteWithoutConfirm('foo', 'apps/bar/data/x.json')).toBe(false);
// app's own non-data subtree (e.g. overwriting its own index.html) requires confirm
expect(isWriteWithoutConfirm('foo', 'apps/foo/index.html')).toBe(false);
// prefix-collision guard: "outputs/" must NOT match "output"
expect(isWriteWithoutConfirm('foo', 'outputs/x.txt')).toBe(false);
});
});
describe('isAppBridgeRequest (message shape guard)', () => {
it('accepts known request types with id + type', () => {
expect(isAppBridgeRequest({ id: 1, type: 'readFile', path: 'output/x' })).toBe(true);
expect(isAppBridgeRequest({ id: 'a', type: 'writeFile', path: 'output/x', content: 'y' })).toBe(true);
expect(isAppBridgeRequest({ id: 2, type: 'listFiles' })).toBe(true);
expect(isAppBridgeRequest({ id: 3, type: 'deleteFile', path: 'output/x' })).toBe(true);
});
it('rejects foreign / malformed messages', () => {
expect(isAppBridgeRequest(null)).toBe(false);
expect(isAppBridgeRequest('hello')).toBe(false);
expect(isAppBridgeRequest({ type: 'readFile' })).toBe(false); // no id
expect(isAppBridgeRequest({ id: 1 })).toBe(false); // no type
expect(isAppBridgeRequest({ id: 1, type: 'exec' })).toBe(false); // unknown type
expect(isAppBridgeRequest({ id: 1, type: 'eval' })).toBe(false);
});
});
describe('detectAppEntry (launch affordance detection)', () => {
it('matches apps/{name}/index.html', () => {
expect(detectAppEntry('apps/invoice-gen/index.html')).toEqual({
appName: 'invoice-gen',
entryPath: 'apps/invoice-gen/index.html',
});
});
it('does not match non-app or nested html', () => {
expect(detectAppEntry('output/report.html')).toBeNull();
expect(detectAppEntry('apps/foo/sub/index.html')).toBeNull();
expect(detectAppEntry('apps/foo/main.html')).toBeNull();
expect(detectAppEntry('index.html')).toBeNull();
});
});
describe('parseAppManifest (lenient app.json parse)', () => {
it('parses a full valid manifest and trims string fields', () => {
expect(parseAppManifest('{"title":" Invoice ","description":" gen ","entry":"main.html"}')).toEqual({
title: 'Invoice',
description: 'gen',
entry: 'main.html',
});
});
it('returns an empty object when fields are absent (still a valid object)', () => {
expect(parseAppManifest('{}')).toEqual({});
});
it('ignores non-string / empty fields', () => {
expect(parseAppManifest('{"title":42,"description":"","entry":null}')).toEqual({});
});
it('falls back to null on missing / invalid / non-object input', () => {
expect(parseAppManifest(null)).toBeNull();
expect(parseAppManifest(undefined)).toBeNull();
expect(parseAppManifest('')).toBeNull();
expect(parseAppManifest(' ')).toBeNull();
expect(parseAppManifest('{not json')).toBeNull();
expect(parseAppManifest('"a string"')).toBeNull();
expect(parseAppManifest('[1,2,3]')).toBeNull();
expect(parseAppManifest('123')).toBeNull();
});
});
describe('resolveAppEntry (manifest entry validation)', () => {
it('defaults to apps/{name}/index.html when no entry given', () => {
expect(resolveAppEntry('foo')).toBe('apps/foo/index.html');
expect(resolveAppEntry('foo', '')).toBe('apps/foo/index.html');
expect(resolveAppEntry('foo', ' ')).toBe('apps/foo/index.html');
expect(resolveAppEntry('foo', null)).toBe('apps/foo/index.html');
});
it('resolves a folder-relative entry under apps/{name}/', () => {
expect(resolveAppEntry('foo', 'main.html')).toBe('apps/foo/main.html');
expect(resolveAppEntry('foo', './ui/app.html')).toBe('apps/foo/ui/app.html');
});
it('accepts an explicit apps/{name}/... entry for the same app', () => {
expect(resolveAppEntry('foo', 'apps/foo/index.html')).toBe('apps/foo/index.html');
});
it('rejects traversal / escaping entries and falls back to default', () => {
expect(resolveAppEntry('foo', '../bar/index.html')).toBe('apps/foo/index.html');
expect(resolveAppEntry('foo', '../../etc/passwd')).toBe('apps/foo/index.html');
expect(resolveAppEntry('foo', '/etc/passwd')).toBe('apps/foo/index.html');
expect(resolveAppEntry('foo', 'C:/Windows')).toBe('apps/foo/index.html');
expect(resolveAppEntry('foo', 'sub\\evil.html')).toBe('apps/foo/index.html');
// another app's folder is out of bounds
expect(resolveAppEntry('foo', 'apps/bar/index.html')).toBe('apps/foo/index.html');
// an entry that normalizes back to the app folder root is rejected
expect(resolveAppEntry('foo', '.')).toBe('apps/foo/index.html');
});
});
describe('deriveAppList (apps-list derivation)', () => {
it('keeps only folders that contain an index.html', () => {
const apps = deriveAppList(['has-index', 'no-index'], name => name === 'has-index');
expect(apps.map(a => a.name)).toEqual(['has-index']);
expect(apps[0]).toMatchObject({
name: 'has-index',
entryPath: 'apps/has-index/index.html',
title: 'has-index',
});
});
it('uses manifest title/description/entry when present', () => {
const apps = deriveAppList(
['note'],
() => true,
() => '{"title":"メモ帳","description":"output を編集","entry":"main.html"}',
);
expect(apps[0]).toEqual({
name: 'note',
title: 'メモ帳',
description: 'output を編集',
entryPath: 'apps/note/main.html',
});
});
it('falls back to folder name when manifest is missing or invalid', () => {
const apps = deriveAppList(
['a', 'b'],
() => true,
name => (name === 'a' ? '{bad json' : null),
);
expect(apps.find(x => x.name === 'a')).toMatchObject({ title: 'a', entryPath: 'apps/a/index.html' });
expect(apps.find(x => x.name === 'b')).toMatchObject({ title: 'b', entryPath: 'apps/b/index.html' });
expect(apps.find(x => x.name === 'a')?.description).toBeUndefined();
});
it('sorts by display title', () => {
const apps = deriveAppList(
['z-app', 'a-app'],
() => true,
name => (name === 'z-app' ? '{"title":"AAA"}' : '{"title":"ZZZ"}'),
);
// z-app has title AAA → sorts first; a-app has title ZZZ → last
expect(apps.map(a => a.title)).toEqual(['AAA', 'ZZZ']);
});
});
// Source-level assertion: the iframe must be sandboxed WITHOUT allow-same-origin.
// (DOM render tests fail in this sandbox env; source-level is the robust check
// per the spec — "source-level or render".) This is the single most important
// security invariant of the AppRunner.
describe('AppRunner iframe sandbox invariant', () => {
const src = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), 'AppRunner.tsx'),
'utf-8',
);
// Extract the literal sandbox attribute value(s) from the JSX (ignores
// comments, which legitimately mention allow-same-origin to explain WHY it's
// absent). There must be exactly one and it must be exactly "allow-scripts".
const sandboxValues = [...src.matchAll(/\bsandbox=["']([^"']*)["']/g)].map(m => m[1]);
it('sets sandbox to exactly "allow-scripts" (opaque origin)', () => {
expect(sandboxValues.length).toBeGreaterThan(0);
for (const v of sandboxValues) expect(v).toBe('allow-scripts');
});
it('sandbox attribute NEVER includes allow-same-origin (would break the security model)', () => {
for (const v of sandboxValues) {
expect(v).not.toContain('allow-same-origin');
}
});
it('validates message source identity (event.source === iframe.contentWindow)', () => {
expect(src).toMatch(/event\.source\s*!==\s*frameWin/);
});
it('uses srcDoc (opaque-origin injection), not a same-origin src URL', () => {
expect(src).toMatch(/srcDoc=\{srcDoc\}/);
});
// The CSP injected into the app HTML must block external network so a
// sandboxed app cannot exfiltrate workspace file contents it was handed.
it("injects a CSP with connect-src 'none' (blocks fetch/XHR/beacon exfil)", () => {
expect(src).toMatch(/connect-src 'none'/);
expect(src).toMatch(/default-src 'none'/);
// and the CSP is actually applied to the loaded HTML
expect(src).toMatch(/injectCsp\(/);
});
});
Binary file not shown.
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
// Fix #7: Switching workspace must reset the local state of the settings
// sub-panels (Monaco editor content, in-progress forms, file preview). The
// react-query keys already include spaceId, so data is refetched fresh — but
// component-local state (e.g. MonacoFileEditor's localContent, which only
// resets on [subdir, filename] change, NOT on content change) would keep the
// previous workspace's text, showing stale content when the new workspace's
// AGENTS.md is empty.
//
// The robust fix is `key={spaceId}` on the per-workspace detail panels so React
// remounts them on workspace switch, deterministically clearing all local
// state. We cannot render the heavy subtree (Monaco + react-query) without a
// DOM harness in this sandbox, so we assert the keying is present in the source
// (the spec explicitly allows this when full rendering is impractical).
const here = dirname(fileURLToPath(import.meta.url));
const src = readFileSync(join(here, 'SpaceDetail.tsx'), 'utf8');
describe('SpaceDetail — per-workspace panels remount on spaceId change', () => {
it('SpaceSettings is keyed on spaceId', () => {
expect(src).toMatch(/<SpaceSettings\s+key=\{spaceId\}/);
});
it('SpaceFiles is keyed on spaceId', () => {
expect(src).toMatch(/<SpaceFiles\s+key=\{spaceId\}/);
});
it('SpaceCalendar is keyed on spaceId', () => {
expect(src).toMatch(/<SpaceCalendar\s+key=\{spaceId\}/);
});
});
+20 -21
View File
@@ -2,27 +2,11 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { getUsageDaily, type UsageBucket, type UsageCounters, type UsageGroupBy } from '../../api';
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
type Preset = 'last7' | 'last30' | 'last90' | 'ytd' | 'custom';
type Gran = 'day' | 'week' | 'month';
type Gran = 'hour' | 'day' | 'week' | 'month';
/** Viewer's local calendar today as 'YYYY-MM-DD' (the server re-buckets UTC
* hours into this local frame via tzOffset). */
function localToday(): string {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
/** Minutes east of UTC for the viewer (JST → +540). */
function localTzOffset(): number {
return -new Date().getTimezoneOffset();
}
function shiftDay(day: string, delta: number): string {
const x = new Date(`${day}T00:00:00.000Z`);
x.setUTCDate(x.getUTCDate() + delta);
return x.toISOString().slice(0, 10);
}
function yearStart(): string {
return `${new Date().getFullYear()}-01-01`;
}
@@ -64,6 +48,16 @@ function bucketKey(day: string, g: Gran): string {
/** Ordered, gap-free bucket list across [from, to] at the chosen granularity. */
function denseBuckets(series: UsageBucket[], from: string, to: string, g: Gran): UsageBucket[] {
const byKey = new Map(series.map((b) => [b.bucket, b]));
// Hour granularity is a 0-23 time-of-day profile: a fixed 24-bucket axis
// independent of the date range (every day folds into the same 24 buckets).
if (g === 'hour') {
const out: UsageBucket[] = [];
for (let h = 0; h < 24; h++) {
const key = String(h).padStart(2, '0');
out.push(byKey.get(key) ?? { bucket: key, segments: {} });
}
return out;
}
const out: UsageBucket[] = [];
const seen = new Set<string>();
let day = from;
@@ -94,6 +88,7 @@ function rangeFor(preset: Preset, customFrom: string, customTo: string): { from:
const CHART_H = 176; // px — stacked-bar plot height (was the h-44 / 11rem class)
const GW = '#6366f1'; // indigo-500 — gateway (source axis)
const DR = '#22c55e'; // green-500 — direct (source axis)
const GWD = '#f59e0b'; // amber-500 — gateway_downstream (external consumers)
const OTHER_COLOR = '#94a3b8'; // slate-400 — folded 'other' bucket
// Distinct palette for dynamic axes (model / route / user / org).
const PALETTE = [
@@ -103,7 +98,7 @@ const PALETTE = [
function colorFor(key: string, index: number, groupBy: UsageGroupBy): string {
if (key === 'other') return OTHER_COLOR;
if (groupBy === 'source') return key === 'gateway' ? GW : DR;
if (groupBy === 'source') return key === 'gateway' ? GW : key === 'gateway_downstream' ? GWD : DR;
return PALETTE[index % PALETTE.length];
}
@@ -133,7 +128,7 @@ export function UsagePage() {
});
const presets: Preset[] = ['last7', 'last30', 'last90', 'ytd', 'custom'];
const grans: Gran[] = ['day', 'week', 'month'];
const grans: Gran[] = ['hour', 'day', 'week', 'month'];
const series: UsageBucket[] = data?.series ?? [];
const keys = data?.keys ?? [];
@@ -167,7 +162,11 @@ export function UsagePage() {
// Display label for a series key (resolve user ids, localize sentinels).
const keyLabel = (key: string): string => {
if (data?.labels?.[key]) return data.labels[key];
if (groupBy === 'source') return t(key === 'gateway' ? 'chart.legendGateway' : 'chart.legendDirect');
if (groupBy === 'source') {
if (key === 'gateway') return t('chart.legendGateway');
if (key === 'gateway_downstream') return t('chart.legendGatewayDownstream');
return t('chart.legendDirect');
}
if (key === 'no-org') return t('axis.noOrg');
if (key === 'other') return t('axis.other');
return key;
@@ -14,9 +14,12 @@ type Phase = 'form' | 'logging-in' | 'saving' | 'done' | 'error';
interface Props {
existingProfile?: BrowserSessionProfile | null;
onClose: () => void;
/** When set, the profile is created under this space (`?spaceId=…`) and the
* space-scoped react-query key is invalidated on save. Omit for personal. */
spaceId?: string;
}
export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
export function AddBrowserSessionDialog({ existingProfile, onClose, spaceId }: Props) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const [phase, setPhase] = useState<Phase>('form');
@@ -42,7 +45,7 @@ export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
storageOrigins: [new URL(startUrl).origin],
loggedInSelector: loggedInSelector || undefined,
loginUrlPatterns: loginUrl ? [loginUrl] : [],
});
}, spaceId);
pid = created.id;
setProfileId(pid);
}
@@ -62,6 +65,7 @@ export function AddBrowserSessionDialog({ existingProfile, onClose }: Props) {
try {
await saveBrowserSession(profileId, sessionId);
qc.invalidateQueries({ queryKey: ['browser-session-profiles'] });
if (spaceId) qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] });
setPhase('done');
setTimeout(onClose, 800);
} catch (e) {
+24 -13
View File
@@ -7,14 +7,22 @@ interface AgentsMdResponse {
content: string;
}
async function fetchAgentsMd(): Promise<AgentsMdResponse> {
const res = await fetch('/api/users/me/agents-md', { credentials: 'include' });
// spaceId をクエリに付けると `data/spaces/{id}/AGENTS.md` を読み書きする。
// 無指定なら従来どおり per-user フォルダ。
function agentsMdUrl(spaceId?: string): string {
return spaceId
? `/api/users/me/agents-md?spaceId=${encodeURIComponent(spaceId)}`
: '/api/users/me/agents-md';
}
async function fetchAgentsMd(spaceId?: string): Promise<AgentsMdResponse> {
const res = await fetch(agentsMdUrl(spaceId), { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<AgentsMdResponse>;
}
async function saveAgentsMd(content: string): Promise<void> {
const res = await fetch('/api/users/me/agents-md', {
async function saveAgentsMd(content: string, spaceId?: string): Promise<void> {
const res = await fetch(agentsMdUrl(spaceId), {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
@@ -26,8 +34,8 @@ async function saveAgentsMd(content: string): Promise<void> {
}
}
async function deleteAgentsMd(): Promise<void> {
const res = await fetch('/api/users/me/agents-md', {
async function deleteAgentsMd(spaceId?: string): Promise<void> {
const res = await fetch(agentsMdUrl(spaceId), {
method: 'DELETE',
credentials: 'include',
});
@@ -36,25 +44,28 @@ async function deleteAgentsMd(): Promise<void> {
interface AgentsMdPanelProps {
onDirtyChange?: (dirty: boolean) => void;
/** When set, operates on the space folder instead of the per-user folder. */
spaceId?: string;
}
export function AgentsMdPanel({ onDirtyChange }: AgentsMdPanelProps) {
export function AgentsMdPanel({ onDirtyChange, spaceId }: AgentsMdPanelProps) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const queryKey = ['agents-md', spaceId ?? null];
const { data, isLoading, error } = useQuery({
queryKey: ['agents-md'],
queryFn: fetchAgentsMd,
queryKey,
queryFn: () => fetchAgentsMd(spaceId),
staleTime: 30_000,
});
const save = useMutation({
mutationFn: saveAgentsMd,
onSuccess: () => qc.invalidateQueries({ queryKey: ['agents-md'] }),
mutationFn: (content: string) => saveAgentsMd(content, spaceId),
onSuccess: () => qc.invalidateQueries({ queryKey }),
});
const del = useMutation({
mutationFn: deleteAgentsMd,
onSuccess: () => qc.invalidateQueries({ queryKey: ['agents-md'] }),
mutationFn: () => deleteAgentsMd(spaceId),
onSuccess: () => qc.invalidateQueries({ queryKey }),
});
if (isLoading) return <div className="p-6 text-[13px] text-slate-400">{t('common.loading')}</div>;
@@ -1,99 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import {
listBrowserSessionProfiles, deleteBrowserSessionProfile, testBrowserSessionProfile,
type BrowserSessionProfile,
} from '../../api';
import { AddBrowserSessionDialog } from './AddBrowserSessionDialog';
function StatusPill({ status }: { status: BrowserSessionProfile['status'] }) {
const { t } = useTranslation('userfolder');
const map: 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',
expired: 'bg-amber-100 dark:bg-amber-500/15 text-amber-800 dark:text-amber-300',
revoked: 'bg-slate-200 text-slate-500',
error: 'bg-rose-100 dark:bg-rose-500/15 text-rose-700 dark:text-rose-300',
};
return <span className={`inline-flex items-center rounded px-2 py-0.5 text-2xs font-medium ${map[status]}`}>{t(`browserSessions.status.${status}`)}</span>;
}
export function BrowserSessionsPanel() {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const { data: profiles = [], isLoading } = useQuery({
queryKey: ['browser-session-profiles'],
queryFn: listBrowserSessionProfiles,
});
const del = useMutation({
mutationFn: (id: number) => deleteBrowserSessionProfile(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['browser-session-profiles'] }),
});
const test = useMutation({
mutationFn: (id: number) => testBrowserSessionProfile(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['browser-session-profiles'] }),
});
const [adding, setAdding] = useState(false);
const [reLoginProfileId, setReLoginProfileId] = useState<number | null>(null);
return (
<div className="h-full overflow-y-auto p-6">
<div className="max-w-2xl space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-800">{t('browserSessions.title')}</h2>
<button onClick={() => { setReLoginProfileId(null); setAdding(true); }}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:bg-accent-deep">
{t('browserSessions.add')}
</button>
</div>
<p className="text-xs text-slate-500">
<Trans t={t} i18nKey="browserSessions.intro" components={{ code: <code className="font-mono text-2xs bg-slate-100 px-1 py-0.5 rounded" /> }} />
</p>
{isLoading && <div className="text-xs text-slate-500">{t('browserSessions.loading')}</div>}
<div className="rounded-md border border-hairline divide-y divide-hairline">
{profiles.length === 0 && !isLoading && (
<div className="px-3 py-6 text-center text-xs text-slate-400">
<div>{t('browserSessions.emptyTitle')}</div>
<div className="mt-1 text-slate-400">{t('browserSessions.emptyHint')}</div>
</div>
)}
{profiles.map(p => (
<div key={p.id} className="flex items-center justify-between px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-slate-800 truncate">{p.label}</span>
<StatusPill status={p.status} />
<span className="text-[10px] font-mono text-slate-400">id={p.id}</span>
</div>
<div className="text-2xs text-slate-500 truncate">{p.startUrl}</div>
{p.lastError && <div className="text-2xs text-rose-600 truncate">{p.lastError}</div>}
<div className="text-2xs text-slate-400">
{p.lastSavedAt ? t('browserSessions.saved', { time: new Date(p.lastSavedAt).toLocaleString() }) : t('browserSessions.notSaved')}
{p.lastUsedAt && t('browserSessions.lastUsed', { time: new Date(p.lastUsedAt).toLocaleString() })}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => test.mutate(p.id)} disabled={test.isPending}
className="text-xs text-slate-700 hover:text-slate-900 px-2 py-1 rounded hover:bg-surface disabled:opacity-50">{t('browserSessions.test')}</button>
<button onClick={() => { setReLoginProfileId(p.id); setAdding(true); }}
className="text-xs text-slate-700 hover:text-slate-900 px-2 py-1 rounded hover:bg-surface">{t('browserSessions.reLogin')}</button>
<button onClick={() => { if (confirm(t('browserSessions.confirmDelete', { label: p.label }))) del.mutate(p.id); }}
className="text-xs text-rose-600 hover:text-rose-800 dark:hover:text-rose-300 px-2 py-1 rounded hover:bg-rose-50 dark:hover:bg-rose-500/15">{t('browserSessions.delete')}</button>
</div>
</div>
))}
</div>
{adding && (
<AddBrowserSessionDialog
existingProfile={reLoginProfileId ? profiles.find(p => p.id === reLoginProfileId) ?? null : null}
onClose={() => { setAdding(false); setReLoginProfileId(null); }}
/>
)}
</div>
</div>
);
}
-156
View File
@@ -1,156 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
// 'agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections' are virtual subdirs (not raw file editor directories)
// ('scripts' / 'templates' were retired 2026-06 — superseded by Skills + the Bash tool)
export type SubdirId = 'agents-md' | 'browser-macros' | 'recordings' | 'trash' | 'memory' | 'browser-sessions' | 'mcp' | 'skills' | 'pets' | 'ssh-connections' | 'notes' | 'subscribed-notes';
/** True for subdirs that have actual files on disk */
export const FILE_SUBDIRS: SubdirId[] = ['browser-macros', 'recordings', 'trash', 'notes'];
export interface FileEntry {
name: string;
size: number;
mtime: string;
}
export interface SubdirFiles {
subdir: SubdirId;
files: FileEntry[];
loading: boolean;
}
interface FileTreeProps {
subdirData: SubdirFiles[];
selectedSubdir: SubdirId | null;
selectedFile: string | null;
onSelectSubdir: (subdir: SubdirId) => void;
onSelectFile: (subdir: SubdirId, file: string) => void;
onDeleteFile: (subdir: SubdirId, file: string) => void;
}
const SUBDIR_LABELS: Record<SubdirId, string> = {
'agents-md': 'AGENTS.md',
'browser-macros': 'browser-macros',
recordings: 'recordings',
trash: 'trash',
memory: 'memory',
'browser-sessions': 'browser-sessions',
mcp: 'MCP',
skills: 'Skills',
pets: 'pets',
'ssh-connections': 'ssh-connections',
// notes label is i18n-translated at render (tree.labels.notes)
notes: '',
'subscribed-notes': 'Subscribed Notes',
};
const SUBDIR_ICONS: Record<SubdirId, string> = {
'agents-md': '📖',
'browser-macros': '🤖',
recordings: '🎬',
trash: '🗑',
memory: '🧠',
'browser-sessions': '🌐',
mcp: '🔌',
skills: '📚',
pets: '◉',
'ssh-connections': '🔐',
notes: '📝',
'subscribed-notes': '🔔',
};
/** Virtual subdirs that don't show a file list (they render custom panel content instead). */
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes', 'memory']);
export function FileTree({
subdirData,
selectedSubdir,
selectedFile,
onSelectSubdir,
onSelectFile,
onDeleteFile,
}: FileTreeProps) {
const { t } = useTranslation('userfolder');
const [hoveredFile, setHoveredFile] = useState<string | null>(null);
const subdirLabel = (subdir: SubdirId) =>
subdir === 'notes' ? t('tree.labels.notes') : SUBDIR_LABELS[subdir];
return (
<div className="flex flex-col h-full overflow-y-auto">
{subdirData.map(({ subdir, files, loading }) => {
const isOpen = selectedSubdir === subdir;
const isVirtual = VIRTUAL_SUBDIRS.has(subdir);
return (
<div key={subdir}>
{/* Subdir header */}
<button
type="button"
onClick={() => onSelectSubdir(subdir)}
className={`w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors hover:bg-surface-2 ${
isOpen ? 'bg-surface-2 text-slate-900' : 'text-slate-600'
}`}
>
<span className="text-2xs">{isOpen ? '▾' : '▸'}</span>
<span>{SUBDIR_ICONS[subdir]}</span>
<span className="flex-1 text-left">{subdirLabel(subdir)}{subdir !== 'agents-md' ? '/' : ''}</span>
{!isVirtual && (
<span className="text-[10px] font-mono text-slate-400 tabular-nums">
{loading ? '…' : files.length}
</span>
)}
</button>
{/* File list — only for non-virtual subdirs */}
{isOpen && !isVirtual && (
<div className="ml-4 border-l border-hairline pl-2 pb-1">
{loading && (
<div className="text-2xs text-slate-400 px-2 py-1.5">Loading</div>
)}
{!loading && files.length === 0 && (
<div className="text-2xs text-slate-400 px-2 py-1.5">Empty</div>
)}
{!loading && files.map(file => {
const fileKey = `${subdir}/${file.name}`;
const isSelected = selectedSubdir === subdir && selectedFile === file.name;
return (
<div
key={file.name}
className={`group flex items-center gap-1 px-2 py-1 rounded text-2xs cursor-pointer transition-colors ${
isSelected
? 'bg-accent text-accent-fg'
: 'text-slate-700 hover:bg-surface-2'
}`}
onMouseEnter={() => setHoveredFile(fileKey)}
onMouseLeave={() => setHoveredFile(null)}
onClick={() => onSelectFile(subdir, file.name)}
>
<span className="flex-1 truncate font-mono">{file.name}</span>
{(hoveredFile === fileKey || isSelected) && (
<button
type="button"
aria-label={`Delete ${file.name}`}
onClick={e => {
e.stopPropagation();
onDeleteFile(subdir, file.name);
}}
className={`flex-shrink-0 w-4 h-4 flex items-center justify-center rounded hover:bg-red-100 dark:hover:bg-red-500/15 hover:text-red-600 transition-colors ${
isSelected ? 'text-accent-fg/70' : 'text-slate-400'
}`}
>
<svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
+34 -13
View File
@@ -47,8 +47,13 @@ async function fetchAdminServers(): Promise<ServerPublic[]> {
return ((await res.json()) as { servers: ServerPublic[] }).servers ?? [];
}
async function fetchUserServers(): Promise<ServerPublic[]> {
const res = await fetch('/api/mcp/user-servers', { credentials: 'include' });
async function fetchUserServers(spaceId?: string): Promise<ServerPublic[]> {
// spaceId 指定時はそのスペースに紐づく自分のサーバーだけを返す。
// 未指定なら従来どおりユーザー所有の(global でない)サーバー一覧。
const url = spaceId
? `/api/mcp/user-servers?spaceId=${encodeURIComponent(spaceId)}`
: '/api/mcp/user-servers';
const res = await fetch(url, { credentials: 'include' });
if (!res.ok) throw new Error(`${res.status}`);
return ((await res.json()) as { servers: ServerPublic[] }).servers ?? [];
}
@@ -59,12 +64,15 @@ async function fetchConnections(): Promise<ConnectionRow[]> {
return ((await res.json()) as { connections: ConnectionRow[] }).connections ?? [];
}
async function upsertServer(body: ServerFormBody, isGlobal: boolean): Promise<void> {
async function upsertServer(body: ServerFormBody, isGlobal: boolean, spaceId?: string): Promise<void> {
const url = isGlobal ? '/api/mcp/servers' : '/api/mcp/user-servers';
// spaceId 指定時(=スペース内の登録)は space_id を載せて per-space サーバーにする。
// global 登録は admin 専用なので space スコープは付与しない。
const payload = spaceId && !isGlobal ? { ...body, space_id: spaceId } : body;
const res = await fetch(url, {
method: 'POST', credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
@@ -275,18 +283,23 @@ function ConnectionBadge({ connection, serverId }: { connection?: ConnectionRow;
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
export function McpPanel({ showToast }: { showToast?: ShowToast }) {
export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?: ShowToast }) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const auth = useAuthState();
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
const userIsAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
// スペース内では「global(全体)」セクションは扱わない。サーバーは常に
// そのスペースに紐づく per-space サーバーとして登録/一覧する。
// global 登録 UI を出す admin 扱いは、スペース外(ユーザーフォルダ)でのみ有効。
const inSpace = typeof spaceId === 'string';
const isAdmin = !inSpace && userIsAdmin;
const [editingId, setEditingId] = useState<string | null>(null);
const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null);
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
qc.invalidateQueries({ queryKey: ['mcp-user-servers'] });
qc.invalidateQueries({ queryKey: ['mcp-user-servers', spaceId ?? null] });
qc.invalidateQueries({ queryKey: ['mcp-connections'] });
};
@@ -295,7 +308,7 @@ export function McpPanel({ showToast }: { showToast?: ShowToast }) {
staleTime: 30_000, enabled: isAdmin,
});
const { data: userServers, isLoading: userLoading } = useQuery({
queryKey: ['mcp-user-servers'], queryFn: fetchUserServers, staleTime: 30_000,
queryKey: ['mcp-user-servers', spaceId ?? null], queryFn: () => fetchUserServers(spaceId), staleTime: 30_000,
});
const { data: connections } = useQuery({
queryKey: ['mcp-connections'], queryFn: fetchConnections, staleTime: 30_000,
@@ -305,7 +318,7 @@ export function McpPanel({ showToast }: { showToast?: ShowToast }) {
const saveMut = useMutation({
mutationFn: ({ body, isGlobal }: { body: ServerFormBody; isGlobal: boolean }) =>
upsertServer(body, isGlobal),
upsertServer(body, isGlobal, spaceId),
onSuccess: () => { invalidateAll(); setEditingId(null); setAddingSection(null); },
onError: (err) => showToast?.(t('mcp.saveFailed', { error: err instanceof Error ? err.message : String(err) }), 'error'),
});
@@ -352,7 +365,13 @@ export function McpPanel({ showToast }: { showToast?: ShowToast }) {
}
return (
<div key={s.id} className="flex items-center gap-3 py-2.5 border-b border-hairline last:border-b-0">
<div
key={s.id}
className="flex items-center gap-3 py-2.5 border-b border-hairline last:border-b-0"
data-testid={inSpace && !isGlobal ? 'space-mcp-item' : undefined}
data-server-id={s.id}
data-server-name={s.name}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[13px] font-medium text-slate-900">{s.name}</span>
@@ -402,7 +421,7 @@ export function McpPanel({ showToast }: { showToast?: ShowToast }) {
const isLoading = globalLoading || userLoading;
return (
<div className="h-full overflow-y-auto">
<div className="h-full overflow-y-auto" data-testid={inSpace ? 'space-mcp-panel' : undefined}>
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('mcp.title')}</h2>
@@ -424,13 +443,15 @@ export function McpPanel({ showToast }: { showToast?: ShowToast }) {
</section>
)}
{/* Personal Servers */}
{/* Personal / space servers */}
{(userServers ?? []).length > 0 && (
<section>
<div className="flex items-center gap-2 mb-2">
<h3 className="text-[13px] font-semibold text-slate-900">{t('mcp.personalServers')}</h3>
</div>
<div>{(userServers ?? []).map(s => renderServerRow(s, false))}</div>
<div data-testid={inSpace ? 'space-mcp-list' : undefined}>
{(userServers ?? []).map(s => renderServerRow(s, false))}
</div>
</section>
)}
+26 -15
View File
@@ -46,8 +46,14 @@ interface MemoryListResponse {
// ── API helpers ───────────────────────────────────────────────────────────────
async function fetchMemoryEntries(): Promise<MemoryListResponse> {
const res = await fetch('/api/local/memory/entries');
// spaceId をクエリに付けると `data/spaces/{id}/memory/` を対象にする。
// 無指定なら従来どおり per-user の memory フォルダ。
function memorySuffix(spaceId?: string): string {
return spaceId ? `?spaceId=${encodeURIComponent(spaceId)}` : '';
}
async function fetchMemoryEntries(spaceId?: string): Promise<MemoryListResponse> {
const res = await fetch(`/api/local/memory/entries${memorySuffix(spaceId)}`);
if (!res.ok) throw new Error(`memory.fetchFailed:${res.status}`);
return res.json();
}
@@ -55,18 +61,19 @@ async function fetchMemoryEntries(): Promise<MemoryListResponse> {
async function upsertMemoryEntry(
name: string,
payload: { description: string; type: MemoryType; body: string },
spaceId?: string,
): Promise<void> {
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}`, {
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}${memorySuffix(spaceId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify(spaceId ? { ...payload, spaceId } : payload),
});
const data = await res.json().catch(() => ({ error: res.statusText }));
if (!res.ok) throw new Error(data.error ?? res.statusText);
}
async function deleteMemoryEntry(name: string): Promise<void> {
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}`, {
async function deleteMemoryEntry(name: string, spaceId?: string): Promise<void> {
const res = await fetch(`/api/local/memory/entries/${encodeURIComponent(name)}${memorySuffix(spaceId)}`, {
method: 'DELETE',
});
if (!res.ok) {
@@ -105,11 +112,13 @@ interface EntryFormState {
function MemoryEntryModal({
initial,
isNew,
spaceId,
onClose,
onSaved,
}: {
initial: EntryFormState;
isNew: boolean;
spaceId?: string;
onClose: () => void;
onSaved: () => void;
}) {
@@ -129,7 +138,7 @@ function MemoryEntryModal({
description: form.description,
type: form.type,
body: form.body,
});
}, spaceId);
onSaved();
onClose();
} catch (e: any) {
@@ -240,12 +249,13 @@ function MemoryEntryModal({
// ── MemoryEntriesPanel ────────────────────────────────────────────────────────
function MemoryEntriesPanel() {
function MemoryEntriesPanel({ spaceId }: { spaceId?: string }) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const memoryKey = ['memory-entries', spaceId ?? null];
const { data, isLoading, error } = useQuery<MemoryListResponse>({
queryKey: ['memory-entries'],
queryFn: fetchMemoryEntries,
queryKey: memoryKey,
queryFn: () => fetchMemoryEntries(spaceId),
});
// Errors thrown by fetchMemoryEntries are "memory.fetchFailed:<status>" sentinels.
@@ -303,8 +313,8 @@ function MemoryEntriesPanel() {
setDeleting(name);
setDeleteError(null);
try {
await deleteMemoryEntry(name);
await qc.invalidateQueries({ queryKey: ['memory-entries'] });
await deleteMemoryEntry(name, spaceId);
await qc.invalidateQueries({ queryKey: memoryKey });
} catch (e: any) {
setDeleteError(t('memory.deleteFailed', { name, error: e.message }));
} finally {
@@ -313,7 +323,7 @@ function MemoryEntriesPanel() {
};
const handleSaved = () => {
void qc.invalidateQueries({ queryKey: ['memory-entries'] });
void qc.invalidateQueries({ queryKey: memoryKey });
};
return (
@@ -474,6 +484,7 @@ function MemoryEntriesPanel() {
<MemoryEntryModal
initial={modal.entry}
isNew={modal.isNew}
spaceId={spaceId}
onClose={() => setModal(null)}
onSaved={handleSaved}
/>
@@ -484,7 +495,7 @@ function MemoryEntriesPanel() {
// ── MemoryPanel (User Folder root) ───────────────────────────────────────────
export function MemoryPanel() {
export function MemoryPanel({ spaceId }: { spaceId?: string } = {}) {
const { t } = useTranslation('userfolder');
return (
<div className="h-full overflow-y-auto">
@@ -495,7 +506,7 @@ export function MemoryPanel() {
{t('memory.intro')}
</p>
</div>
<MemoryEntriesPanel />
<MemoryEntriesPanel spaceId={spaceId} />
</div>
</div>
);
@@ -1,6 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import Editor, { OnMount } from '@monaco-editor/react';
import type { SubdirId } from './FileTree';
// Folder section the edited file belongs to. Inlined here after the User Folder
// tab (and its FileTree, which used to own this type) was retired; AgentsMdPanel
// — the sole remaining consumer — passes 'agents-md', and the read-only check
// below still recognizes the legacy 'trash'/'memory' sections.
export type SubdirId = 'agents-md' | 'browser-macros' | 'recordings' | 'trash' | 'memory' | 'browser-sessions' | 'mcp' | 'skills' | 'pets' | 'ssh-connections';
interface MonacoFileEditorProps {
subdir: SubdirId;
@@ -97,7 +102,7 @@ export function MonacoFileEditor({ subdir, filename, content, mtime, size, onSav
};
return (
<div className="flex flex-col h-full overflow-hidden">
<div data-testid="monaco-file-editor" className="flex flex-col h-full overflow-hidden">
{/* File header */}
<div className="flex-shrink-0 flex items-center gap-2 px-4 py-2.5 border-b border-hairline bg-canvas">
<span className="text-xs font-mono font-semibold text-slate-800 truncate">
@@ -150,6 +155,7 @@ export function MonacoFileEditor({ subdir, filename, content, mtime, size, onSav
{!isReadOnly && (
<button
type="button"
data-testid="monaco-save"
onClick={handleSave}
disabled={!dirty || saving}
className="px-3 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@@ -1,108 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
type WritableSubdir = 'browser-macros';
interface NewFileFormProps {
subdir: WritableSubdir;
existingFilenames: string[];
onCreate: (filename: string, skeleton: string) => Promise<void>;
}
const TODAY = new Date().toISOString().slice(0, 10);
const SKELETON: Record<WritableSubdir, { ext: string; body: string }> = {
'browser-macros': {
ext: '.js',
body: `---
description: <short summary>
# session_profile_id: 1 # optional: bind to a saved login profile
params:
# url: { type: string, required: true }
---
/**
* Generated ${TODAY} via User Folder UI.
*/
export async function main({ context, params }) {
const page = await context.newPage();
// await page.goto(params.url);
return { ok: true };
}
`,
},
};
const SUBDIR_LABEL_KEY: Record<WritableSubdir, string> = {
'browser-macros': 'newFile.label.browserMacros',
};
export function NewFileForm({ subdir, existingFilenames, onCreate }: NewFileFormProps) {
const { t } = useTranslation('userfolder');
const [name, setName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const config = SKELETON[subdir];
const label = t(SUBDIR_LABEL_KEY[subdir]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const baseName = name.trim().replace(new RegExp(`\\${config.ext}$`, 'i'), '');
if (!baseName) {
setError(t('newFile.errorNameRequired'));
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(baseName)) {
setError(t('newFile.errorNamePattern'));
return;
}
const filename = `${baseName}${config.ext}`;
if (existingFilenames.includes(filename)) {
setError(t('newFile.errorExists', { filename }));
return;
}
setSubmitting(true);
try {
await onCreate(filename, config.body);
setName('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="border-t border-hairline pt-4 mt-4">
<h3 className="text-[13px] font-semibold text-slate-900 mb-2">
{t('newFile.heading', { label })}
</h3>
<div className="flex items-stretch gap-2 max-w-md">
<input
type="text"
className="flex-1 min-w-0 border border-hairline rounded px-2 py-1 text-[13px] font-mono"
placeholder={t('newFile.namePlaceholder', { ext: config.ext })}
value={name}
onChange={(e) => setName(e.target.value)}
disabled={submitting}
pattern="[a-zA-Z0-9_-]+"
/>
<button
type="submit"
className="shrink-0 px-3 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
disabled={submitting || !name.trim()}
>
{submitting ? t('common.creating') : t('common.create')}
</button>
</div>
{error && (
<div className="mt-2 text-xs text-red-600">{error}</div>
)}
<p className="mt-2 text-2xs text-slate-500">
{t('newFile.hint')}
</p>
</form>
);
}
-345
View File
@@ -1,345 +0,0 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface NotesPanelProps {
filePath: string | null; // e.g. "cve/foo.md"
onSaved?: () => void;
onSelectFile?: (filePath: string) => void; // called after new note created
}
interface ParsedFm {
title: string;
visibility: 'private' | 'org' | 'public';
scope_org_id: string;
mode_hint: '' | 'search' | 'inject';
tags: string;
body: string;
}
function parseMdToFmAndBody(md: string): ParsedFm {
// Lightweight FM parse (UI-side)
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(md);
if (!m) {
return { title: '', visibility: 'private', scope_org_id: '', mode_hint: '', tags: '', body: md };
}
const fmText = m[1]!;
const body = m[2] ?? '';
const get = (k: string): string => {
const re = new RegExp(`^${k}:\\s*(.+)$`, 'm');
const found = re.exec(fmText);
return found ? found[1]!.trim().replace(/^['"]|['"]$/g, '') : '';
};
const tagsArr = /^tags:\s*\[([^\]]*)\]$/m.exec(fmText);
return {
title: get('title'),
visibility: (get('visibility') as ParsedFm['visibility']) || 'private',
scope_org_id: get('scope_org_id'),
mode_hint: (get('mode_hint') as ParsedFm['mode_hint']) || '',
tags: tagsArr ? tagsArr[1]!.split(',').map((s) => s.trim()).filter(Boolean).join(', ') : '',
body,
};
}
function serializeFm(p: ParsedFm): string {
const fm: string[] = ['---'];
if (p.title) fm.push(`title: ${p.title}`);
fm.push(`visibility: ${p.visibility}`);
if (p.visibility === 'org' && p.scope_org_id) fm.push(`scope_org_id: ${p.scope_org_id}`);
if (p.mode_hint) fm.push(`mode_hint: ${p.mode_hint}`);
const tagsArr = p.tags.split(',').map((s) => s.trim()).filter(Boolean);
if (tagsArr.length > 0) fm.push(`tags: [${tagsArr.join(', ')}]`);
fm.push('---');
fm.push('');
fm.push(p.body);
return fm.join('\n');
}
const NAME_RE = /^[a-zA-Z0-9._-]+$/;
function NewNoteForm({ onCreated }: { onCreated: (filePath: string) => void }) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const [folder, setFolder] = useState('');
const [fileName, setFileName] = useState('');
const [error, setError] = useState('');
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
const fn = fileName.endsWith('.md') ? fileName : `${fileName}.md`;
if (!NAME_RE.test(folder)) { setError(t('notes.newNoteForm.errorFolder')); return; }
if (!NAME_RE.test(fn)) { setError(t('notes.newNoteForm.errorFile')); return; }
setError('');
setCreating(true);
try {
const stub = `---\nvisibility: private\n---\n\n`;
const r = await fetch(
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(`${folder}/${fn}`)}`,
{
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: stub,
},
);
if (!r.ok) {
const j = await r.json().catch(() => ({ error: 'save failed' }));
throw new Error((j as { error?: string }).error ?? 'save failed');
}
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
onCreated(`${folder}/${fn}`);
} catch (err) {
setError((err as Error).message);
} finally {
setCreating(false);
}
};
return (
<div className="mt-6 border border-hairline rounded-md p-4 bg-surface-2/40">
<p className="text-[13px] font-semibold text-slate-700 mb-3">{t('notes.newNoteForm.title')}</p>
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<input
className="flex-1 border border-hairline rounded px-2 py-1 text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
placeholder={t('notes.newNoteForm.folderPlaceholder')}
value={folder}
onChange={(e) => setFolder(e.target.value)}
/>
<span className="text-slate-400 text-[13px]">/</span>
<input
className="flex-1 border border-hairline rounded px-2 py-1 text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
placeholder={t('notes.newNoteForm.filePlaceholder')}
value={fileName}
onChange={(e) => setFileName(e.target.value)}
/>
</div>
{error && <p className="text-2xs text-red-600">{error}</p>}
<button
type="button"
className="self-start px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
disabled={creating || !folder || !fileName}
onClick={handleCreate}
>
{creating ? t('notes.newNoteForm.creating') : t('notes.newNoteForm.create')}
</button>
</div>
</div>
);
}
export function NotesPanel({ filePath, onSaved, onSelectFile }: NotesPanelProps) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const { data: fileText, isLoading, isError } = useQuery({
queryKey: ['notes-file', filePath],
queryFn: async () => {
if (!filePath) return '';
const r = await fetch(
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(filePath)}`,
{ credentials: 'include' },
);
if (!r.ok) throw new Error(`${r.status}`);
return r.text();
},
enabled: !!filePath,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
const [state, setState] = useState<ParsedFm>({
title: '',
visibility: 'private',
scope_org_id: '',
mode_hint: '',
tags: '',
body: '',
});
useEffect(() => {
if (fileText !== undefined) setState(parseMdToFmAndBody(fileText));
}, [fileText]);
const save = useMutation({
mutationFn: async () => {
if (!filePath) return;
const md = serializeFm(state);
const r = await fetch(
`/api/users/me/folder/file?subdir=notes&path=${encodeURIComponent(filePath)}`,
{
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: md,
},
);
if (!r.ok) {
const j = await r.json().catch(() => ({ error: 'save failed' }));
throw new Error((j as { error?: string }).error ?? 'save failed');
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notes-file', filePath] });
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
onSaved?.();
},
});
if (!filePath) {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
<div className="mb-6 flex gap-3">
<span className="text-2xl leading-none mt-0.5 select-none" aria-hidden>📝</span>
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-slate-900">{t('notes.introTitle')}</h2>
<p className="text-[13px] text-slate-500 mt-1 leading-relaxed">
{t('notes.introBody')}
</p>
</div>
</div>
<div className="bg-surface-2 rounded-md p-4 text-[13px] font-mono text-slate-600 whitespace-pre leading-relaxed">
{t('notes.formatExample')}
</div>
<div className="mt-6 text-[13px] text-slate-500 space-y-2">
<p>
<span className="font-semibold text-slate-700">{t('notes.shareLabel')}</span>{' '}
{t('notes.shareBody')}
</p>
<p>
<span className="font-semibold text-slate-700">{t('notes.agentLabel')}</span>{' '}
{t('notes.agentBody')}
</p>
</div>
<NewNoteForm onCreated={(path) => {
onSaved?.();
onSelectFile?.(path);
}} />
</div>
</div>
);
}
if (isLoading) {
return (
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
Loading
</div>
);
}
if (isError) {
return (
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
{t('notes.loadFailed')}
</div>
);
}
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="flex-shrink-0 px-4 py-3 border-b border-hairline bg-surface-2/30">
<div className="flex items-center justify-between">
<div>
<h2 className="text-[13px] font-semibold text-slate-900 font-mono">{filePath}</h2>
<p className="text-2xs text-slate-500 mt-0.5">
{t('notes.fmHint')}
</p>
</div>
<button
type="button"
className="px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
disabled={save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? 'Saving…' : 'Save'}
</button>
</div>
{save.isError && (
<p className="mt-1 text-2xs text-red-600">{(save.error as Error).message}</p>
)}
</div>
{/* FM form */}
<div className="flex-shrink-0 px-4 py-3 border-b border-hairline bg-surface-2/20">
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
{/* Title */}
<label className="flex flex-col gap-0.5">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Title</span>
<input
className="border border-hairline rounded px-2 py-1 text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
value={state.title}
onChange={(e) => setState({ ...state, title: e.target.value })}
placeholder="(optional)"
/>
</label>
{/* Visibility */}
<label className="flex flex-col gap-0.5">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Visibility</span>
<select
className="border border-hairline rounded px-2 py-1 text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
value={state.visibility}
onChange={(e) => setState({ ...state, visibility: e.target.value as ParsedFm['visibility'] })}
>
<option value="private">private</option>
<option value="org">org</option>
<option value="public">public</option>
</select>
</label>
{/* Scope org id — conditional */}
{state.visibility === 'org' && (
<label className="flex flex-col gap-0.5">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Scope Org ID</span>
<input
className="border border-hairline rounded px-2 py-1 text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
value={state.scope_org_id}
onChange={(e) => setState({ ...state, scope_org_id: e.target.value })}
placeholder="gitea-org-name"
/>
</label>
)}
{/* Mode hint */}
<label className="flex flex-col gap-0.5">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">Mode Hint</span>
<select
className="border border-hairline rounded px-2 py-1 text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
value={state.mode_hint}
onChange={(e) => setState({ ...state, mode_hint: e.target.value as ParsedFm['mode_hint'] })}
>
<option value="">(none)</option>
<option value="search">search</option>
<option value="inject">inject</option>
</select>
</label>
{/* Tags */}
<label className="flex flex-col gap-0.5 col-span-2">
<span className="text-2xs font-medium text-slate-500 uppercase tracking-wide">{t('notes.tags')}</span>
<input
className="border border-hairline rounded px-2 py-1 text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
value={state.tags}
onChange={(e) => setState({ ...state, tags: e.target.value })}
placeholder="security, cve, ..."
/>
</label>
</div>
</div>
{/* Markdown body */}
<div className="flex-1 min-h-0 overflow-hidden">
<textarea
className="w-full h-full resize-none p-4 font-mono text-[13px] text-slate-800 bg-canvas focus:outline-none"
value={state.body}
onChange={(e) => setState({ ...state, body: e.target.value })}
placeholder="Markdown body…"
spellCheck={false}
/>
</div>
</div>
);
}
@@ -1,331 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listBrowserSessionProfiles } from '../../api';
import { useBackdropClose } from '../../lib/useBackdropClose';
export interface ParamHint {
name: string;
valueToReplace: string;
type: 'string' | 'number' | 'boolean';
}
interface SaveAsScriptDialogProps {
/** The name of the recording (without .json extension). */
recordingName: string;
onClose: () => void;
/** Called with the new script filename (e.g. "my-script.js") after a successful compile. */
onSuccess: (scriptName: string) => void;
}
const SCRIPT_NAME_RE = /^[A-Za-z0-9_\-.]+$/;
async function apiCompileScript(body: {
recordingName: string;
scriptName: string;
description: string;
sessionProfileId?: number;
paramHints?: ParamHint[];
overwrite?: boolean;
}): Promise<{ ok: boolean; scriptName: string }> {
const { overwrite, ...rest } = body;
const qs = overwrite ? '?overwrite=true' : '';
const res = await fetch(`/api/users/me/browser-macros/compile${qs}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rest),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const err = new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
(err as any).status = res.status;
throw err;
}
return res.json();
}
function emptyHint(): ParamHint {
return { name: '', valueToReplace: '', type: 'string' };
}
export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAsScriptDialogProps) {
const qc = useQueryClient();
const [scriptName, setScriptName] = useState(recordingName);
const [description, setDescription] = useState('');
const [sessionProfileId, setSessionProfileId] = useState('');
const [paramHints, setParamHints] = useState<ParamHint[]>([]);
const [overwrite, setOverwrite] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [conflictError, setConflictError] = useState<string | null>(null);
const backdrop = useBackdropClose(onClose);
const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({
queryKey: ['browser-session-profiles'],
queryFn: listBrowserSessionProfiles,
staleTime: 60 * 1000,
});
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
const compileMutation = useMutation({
mutationFn: apiCompileScript,
onSuccess: (data) => {
// Invalidate browser-macros list so the new file appears
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
onSuccess(data.scriptName);
},
onError: (err: any) => {
if (err.status === 409) {
setConflictError('Script already exists; check the overwrite box and retry.');
}
// Other errors surface via compileMutation.error
},
});
function validate(): boolean {
if (!scriptName.trim()) {
setValidationError('Script name is required.');
return false;
}
const nameWithoutExt = scriptName.endsWith('.js') ? scriptName.slice(0, -3) : scriptName;
if (!SCRIPT_NAME_RE.test(nameWithoutExt)) {
setValidationError('Script name may only contain letters, numbers, dashes, underscores, and dots.');
return false;
}
if (!description.trim()) {
setValidationError('Description is required.');
return false;
}
for (let i = 0; i < paramHints.length; i++) {
const h = paramHints[i]!;
if (!h.name.trim() || !h.valueToReplace.trim()) {
setValidationError(`Param hint #${i + 1} must have a name and value to replace.`);
return false;
}
}
setValidationError(null);
setConflictError(null);
return true;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const profileId = sessionProfileId.trim() !== '' ? parseInt(sessionProfileId, 10) : undefined;
compileMutation.mutate({
recordingName,
scriptName: scriptName.trim(),
description: description.trim(),
sessionProfileId: profileId !== undefined && !isNaN(profileId) ? profileId : undefined,
paramHints: paramHints.length > 0 ? paramHints : undefined,
overwrite,
});
}
function addHint() {
setParamHints(prev => [...prev, emptyHint()]);
}
function updateHint(idx: number, patch: Partial<ParamHint>) {
setParamHints(prev => prev.map((h, i) => i === idx ? { ...h, ...patch } : h));
}
function removeHint(idx: number) {
setParamHints(prev => prev.filter((_, i) => i !== idx));
}
const isSubmitting = compileMutation.isPending;
const submitError = compileMutation.isError && !conflictError
? ((compileMutation.error as any)?.message ?? 'Compile failed')
: null;
return (
/* Backdrop */
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
{...backdrop}
>
<div className="bg-surface rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-hairline">
<span className="text-sm font-semibold text-slate-800 flex-1">Save as Script</span>
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-surface-2 text-slate-400 hover:text-slate-700 transition-colors"
aria-label="Close"
>
<svg viewBox="0 0 16 16" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-5 py-4 overflow-y-auto flex-1">
{/* Recording name (read-only) */}
<div className="flex flex-col gap-1">
<label className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Recording
</label>
<div className="px-3 py-2 rounded-md bg-surface-2 text-xs font-mono text-slate-700 border border-hairline">
{recordingName}.json
</div>
</div>
{/* Script name */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-script-name" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Script name <span className="text-red-500">*</span>
</label>
<input
id="sas-script-name"
type="text"
value={scriptName}
onChange={e => setScriptName(e.target.value)}
placeholder="my-script"
className="px-3 py-2 rounded-md border border-hairline text-[13px] font-mono focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
/>
<span className="text-[10px] text-slate-400">Alphanumeric, dashes, underscores, dots. A .js extension will be added automatically.</span>
</div>
{/* Description */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-description" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Description <span className="text-red-500">*</span>
</label>
<textarea
id="sas-description"
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
placeholder="What does this script do?"
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent resize-none"
/>
</div>
{/* Session profile */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-session-profile" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Session profile <span className="text-slate-400 font-normal">(optional)</span>
</label>
{profilesLoading ? (
<div className="px-3 py-2 text-xs text-slate-500">Loading profiles</div>
) : activeSessionProfiles.length === 0 ? (
<div className="px-3 py-2 text-xs text-slate-500">
No active session profiles. Create one in the Browser tab to enable authenticated automation.
</div>
) : (
<select
id="sas-session-profile"
value={sessionProfileId}
onChange={e => setSessionProfileId(e.target.value)}
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
>
<option value="">None</option>
{activeSessionProfiles.map(p => (
<option key={p.id} value={String(p.id)}>{p.label} (#{p.id})</option>
))}
</select>
)}
</div>
{/* Param hints */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide flex-1">
Param hints <span className="text-slate-400 font-normal">(optional)</span>
</span>
<button
type="button"
onClick={addHint}
className="text-2xs px-2 py-1 rounded border border-hairline text-slate-600 hover:bg-surface-2 transition-colors"
>
+ Add hint
</button>
</div>
{paramHints.map((hint, idx) => (
<div key={idx} className="flex gap-2 items-start p-2 rounded-md border border-hairline bg-surface-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<input
type="text"
value={hint.name}
onChange={e => updateHint(idx, { name: e.target.value })}
placeholder="param name"
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-canvas focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<input
type="text"
value={hint.valueToReplace}
onChange={e => updateHint(idx, { valueToReplace: e.target.value })}
placeholder="value to replace (literal)"
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-canvas focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<select
value={hint.type}
onChange={e => updateHint(idx, { type: e.target.value as ParamHint['type'] })}
className="px-2 py-1 rounded border border-hairline text-2xs bg-canvas focus:outline-none focus:ring-1 focus:ring-accent/30"
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<button
type="button"
onClick={() => removeHint(idx)}
aria-label="Remove hint"
className="mt-1 w-5 h-5 flex-shrink-0 flex items-center justify-center rounded hover:bg-red-100 dark:hover:bg-red-500/15 hover:text-red-600 text-slate-400 transition-colors"
>
<svg viewBox="0 0 16 16" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
))}
</div>
{/* Overwrite checkbox */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={overwrite}
onChange={e => { setOverwrite(e.target.checked); setConflictError(null); }}
className="rounded border-hairline accent-accent"
/>
<span className="text-xs text-slate-700">Overwrite if script already exists</span>
</label>
{/* Errors */}
{(validationError || conflictError || submitError) && (
<div className="px-3 py-2 rounded-md bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 text-xs text-red-700 dark:text-red-300">
{validationError ?? conflictError ?? submitError}
</div>
)}
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-hairline bg-surface-2/50">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-1.5 rounded-md text-xs font-medium text-slate-700 hover:bg-surface-2 disabled:opacity-50 transition-colors border border-hairline"
>
Cancel
</button>
<button
type="submit"
form=""
onClick={handleSubmit}
disabled={isSubmitting}
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Compiling…' : 'Save as Script'}
</button>
</div>
</div>
</div>
);
}
@@ -1,163 +0,0 @@
/**
* ScriptDiffReview — side-by-side diff view for a pending .next.js patch.
*
* Design choice: .next.js files appear as sibling rows in the FileTree just like
* any other file. Clicking a .next.js file opens this component (instead of
* MonacoFileEditor). The diff view shows the current .js on the left (original)
* and the candidate .next.js on the right (modified), read-only.
*
* Accept: archives scripts/{name}.js to trash, renames .next.js into place.
* Reject: moves .next.js to trash; original is untouched.
* Both actions invalidate the scripts listing and navigate back to scripts/{name}.js.
*/
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DiffEditor } from '@monaco-editor/react';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
interface ScriptDiffReviewProps {
/** The bare script name without extension, e.g. "myscript" */
scriptName: string;
onClose: (acceptedScript?: string) => void;
showToast?: ShowToast;
}
interface DiffResponse {
current: string | null;
candidate: string;
candidateMtime: string;
}
async function fetchDiff(name: string): Promise<DiffResponse> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/diff`, {
credentials: 'include',
});
if (res.status === 404) throw new Error('No pending patch found.');
if (!res.ok) throw new Error(`Diff fetch failed: ${res.status}`);
return res.json() as Promise<DiffResponse>;
}
async function postAccept(name: string): Promise<void> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/accept`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`Accept failed: ${res.status}`);
}
async function postReject(name: string): Promise<void> {
const res = await fetch(`/api/users/me/browser-macros/${encodeURIComponent(name)}/reject`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) throw new Error(`Reject failed: ${res.status}`);
}
export function ScriptDiffReview({ scriptName, onClose, showToast }: ScriptDiffReviewProps) {
const qc = useQueryClient();
const notifyError = (label: string, err: unknown) => {
const msg = `${label}: ${err instanceof Error ? err.message : 'Unknown error'}`;
if (showToast) showToast(msg, 'error');
else console.error(msg);
};
const diffQuery = useQuery<DiffResponse, Error>({
queryKey: ['userfolder', 'diff', scriptName],
queryFn: () => fetchDiff(scriptName),
staleTime: 10_000,
refetchOnWindowFocus: false,
});
const candidateMtimeLabel = diffQuery.data?.candidateMtime
? new Date(diffQuery.data.candidateMtime).toLocaleString()
: '';
async function handleAccept() {
try {
await postAccept(scriptName);
// Invalidate browser-macros listing so the .next.js row disappears
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
qc.removeQueries({ queryKey: ['userfolder', 'diff', scriptName] });
// Navigate back to the now-accepted script
onClose(`${scriptName}.js`);
} catch (err) {
notifyError('Accept failed', err);
}
}
async function handleReject() {
try {
await postReject(scriptName);
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
qc.removeQueries({ queryKey: ['userfolder', 'diff', scriptName] });
// Navigate back to the original (unchanged) script if it exists; otherwise close
const hasOriginal = diffQuery.data?.current !== null;
onClose(hasOriginal ? `${scriptName}.js` : undefined);
} catch (err) {
notifyError('Reject failed', err);
}
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex-shrink-0 flex items-center gap-3 px-4 py-2.5 border-b border-hairline bg-surface-2/50">
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-slate-700">Patch review: </span>
<span className="font-mono text-xs text-slate-600">{scriptName}.next.js</span>
{candidateMtimeLabel && (
<span className="ml-2 text-2xs text-slate-400">{candidateMtimeLabel}</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-2xs text-slate-400 font-mono">original patch</span>
<button
type="button"
onClick={handleReject}
disabled={diffQuery.isLoading}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
Reject
</button>
<button
type="button"
onClick={handleAccept}
disabled={diffQuery.isLoading}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
>
Accept
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-hidden">
{diffQuery.isLoading && (
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
Loading diff
</div>
)}
{diffQuery.isError && (
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
{diffQuery.error?.message ?? 'Failed to load diff.'}
</div>
)}
{diffQuery.data && (
<DiffEditor
height="100%"
language="javascript"
original={diffQuery.data.current ?? ''}
modified={diffQuery.data.candidate}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 12,
}}
/>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { SkillsForm } from '../settings/SkillsForm';
export function SkillsPanel() {
export function SkillsPanel({ spaceId }: { spaceId?: string } = {}) {
return (
<div className="h-full overflow-y-auto">
<div className="max-w-4xl mx-auto px-6 py-8">
<SkillsForm />
<SkillsForm spaceId={spaceId} />
</div>
</div>
);
@@ -15,8 +15,13 @@ interface CreateResponse {
publicKey?: string | null;
}
async function fetchConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
const res = await fetch('/api/ssh/connections', { credentials: 'include' });
async function fetchConnections(spaceId?: string): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
// spaceId 指定時はそのスペースの接続だけを一覧(バックエンドが可視性を検証)。
// 未指定なら従来どおりユーザー所有+global を返す。
const url = spaceId
? `/api/ssh/connections?spaceId=${encodeURIComponent(spaceId)}`
: '/api/ssh/connections';
const res = await fetch(url, { credentials: 'include' });
if (res.status === 404) {
return { list: [], sshDisabled: true };
}
@@ -25,12 +30,14 @@ async function fetchConnections(): Promise<{ list: SshConnection[]; sshDisabled:
return { list: data.connections ?? [], sshDisabled: false };
}
async function apiCreate(body: Record<string, unknown>): Promise<CreateResponse> {
async function apiCreate(body: Record<string, unknown>, spaceId?: string): Promise<CreateResponse> {
// spaceId 指定時は space_id を載せてそのスペース所属の接続として作成する。
const payload = spaceId ? { ...body, space_id: spaceId } : body;
const res = await fetch('/api/ssh/connections', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
body: JSON.stringify(payload),
});
if (!res.ok) {
const txt = await res.text();
@@ -121,15 +128,17 @@ function parseApiError(rawText: string, status: number): string {
interface SshConnectionsPanelProps {
/** Render personal+globals (user mode) or only globals via admin endpoints. */
scope?: 'user';
/** スペース内で表示する場合の space id。指定時は一覧/作成がそのスペースに紐づく。 */
spaceId?: string;
showToast?: (msg: string, variant?: 'success' | 'error') => void;
}
export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}) {
export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelProps = {}) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const { data, isLoading, error } = useQuery({
queryKey: ['ssh', 'connections'],
queryFn: fetchConnections,
queryKey: ['ssh', 'connections', spaceId ?? null],
queryFn: () => fetchConnections(spaceId),
staleTime: 15_000,
});
@@ -142,10 +151,13 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
freshlyGenerated: boolean;
} | null>(null);
const invalidateConnections = () =>
qc.invalidateQueries({ queryKey: ['ssh', 'connections', spaceId ?? null] });
const createMutation = useMutation({
mutationFn: apiCreate,
mutationFn: (body: Record<string, unknown>) => apiCreate(body, spaceId),
onSuccess: (resp) => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
invalidateConnections();
setCreating(false);
showToast?.(t('ssh.toast.created'), 'success');
// If the server returned a public key (always for keypairSource=generate;
@@ -179,7 +191,7 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
const patchMutation = useMutation({
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) => apiPatch(id, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
invalidateConnections();
setEditingId(null);
showToast?.(t('ssh.toast.updated'), 'success');
},
@@ -187,7 +199,7 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
const deleteMutation = useMutation({
mutationFn: apiDelete,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
invalidateConnections();
showToast?.(t('ssh.toast.deleted'), 'success');
},
onError: (e) => {
@@ -197,7 +209,7 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
const testMutation = useMutation({
mutationFn: apiTest,
onSuccess: (response, id) => {
qc.invalidateQueries({ queryKey: ['ssh', 'connections'] });
invalidateConnections();
// Surface result. pass = already verified; first_observe/mismatch = needs confirm.
if (response.verdict === 'pass') {
showToast?.(t('ssh.toast.hostKeyMatch', { fingerprint: response.fingerprint.slice(0, 20) }), 'success');
@@ -235,7 +247,7 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
const globals = (data?.list ?? []).filter(c => c.ownerId === null);
return (
<div className="h-full overflow-y-auto">
<div className="h-full overflow-y-auto" data-testid={spaceId ? 'space-ssh-panel' : undefined}>
<div className="max-w-3xl mx-auto px-6 py-6">
<div className="flex items-center justify-between mb-4">
<div>
@@ -275,7 +287,10 @@ export function SshConnectionsPanel({ showToast }: SshConnectionsPanelProps = {}
{t('ssh.ownEmpty')}
</div>
)}
<ul className="divide-y divide-hairline mb-6">
<ul
className="divide-y divide-hairline mb-6"
data-testid={spaceId ? 'space-ssh-list' : undefined}
>
{owned.map(c => (
<ConnectionRow
key={c.id}
@@ -385,7 +400,7 @@ function ConnectionRow(props: ConnectionRowProps) {
const disabled = c.disabledByAdmin || !c.enabled;
return (
<li className="py-3">
<li className="py-3" data-connection-id={c.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
@@ -1,541 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { MarkdownText } from '../../lib/markdown-text';
import { useBackdropClose } from '../../lib/useBackdropClose';
interface Subscription {
consumer_user_id: string;
publisher_user_id: string;
folder: string;
mode: 'search' | 'inject';
enabled: number;
}
interface DiscoverRow {
owner_id: string;
folder: string;
file_name: string;
title: string | null;
visibility: string;
mode_hint: string | null;
updated_at: number;
}
interface InjectItem {
owner_id: string;
folder: string;
file_name: string;
size_kb: number;
}
interface InjectPreview {
items: InjectItem[];
total_kb: number;
budget_kb: number;
per_note_max_kb: number;
}
function NotesListExpanded({
ownerId,
folder,
onSelectNote,
}: {
ownerId: string;
folder: string;
onSelectNote: (fileName: string) => void;
}) {
const { t } = useTranslation('userfolder');
const list = useQuery<{ rows: DiscoverRow[] }>({
queryKey: ['notes-folder-list', ownerId, folder],
queryFn: async () => {
const r = await fetch(
`/api/notes/discover?owner_id=${encodeURIComponent(ownerId)}&folder=${encodeURIComponent(folder)}&limit=200`,
{ credentials: 'include' },
);
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 30_000,
});
if (list.isLoading) return <p className="text-2xs text-slate-400 pl-3 py-1">{t('subscriptions.loading')}</p>;
if (list.isError) return <p className="text-2xs text-red-500 pl-3 py-1">{t('subscriptions.notesLoadFailed')}</p>;
const rows = list.data?.rows ?? [];
if (rows.length === 0) return <p className="text-2xs text-slate-400 pl-3 py-1">{t('subscriptions.empty')}</p>;
return (
<ul className="pl-3 pt-1 pb-1 space-y-0.5">
{rows.map((n) => (
<li key={n.file_name}>
<button
type="button"
onClick={() => onSelectNote(n.file_name)}
className="w-full text-left flex items-center gap-2 px-2 py-1 rounded text-2xs hover:bg-surface-2/60 transition-colors"
title={`${n.owner_id}/${n.folder}/${n.file_name}`}
>
<span className="text-slate-400 flex-shrink-0">📄</span>
<span className="flex-1 min-w-0 truncate text-slate-700">
{n.title || <span className="font-mono text-slate-500">{n.file_name}</span>}
</span>
{n.mode_hint && (
<span className="text-slate-400 text-[10px] font-mono">{n.mode_hint}</span>
)}
</button>
</li>
))}
</ul>
);
}
function NoteContentModal({
ownerId,
folder,
fileName,
onClose,
}: {
ownerId: string;
folder: string;
fileName: string;
onClose: () => void;
}) {
const { t } = useTranslation('userfolder');
const note = useQuery<{ fm: Record<string, unknown>; body: string; content: string }>({
queryKey: ['notes-cross-user-file', ownerId, folder, fileName],
queryFn: async () => {
const r = await fetch(
`/api/notes/file?owner_id=${encodeURIComponent(ownerId)}&folder=${encodeURIComponent(folder)}&file_name=${encodeURIComponent(fileName)}`,
{ credentials: 'include' },
);
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 30_000,
});
const title = (note.data?.fm.title as string | undefined) || fileName;
const backdrop = useBackdropClose(onClose);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
{...backdrop}
>
<div
className="bg-surface rounded-md shadow-lg w-full max-w-3xl max-h-[85vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-center justify-between border-b border-hairline px-4 py-3 flex-shrink-0">
<div className="min-w-0">
<h3 className="text-[13px] font-semibold text-slate-900 truncate">{title}</h3>
<p className="text-2xs text-slate-500 font-mono truncate">
{ownerId}/{folder}/{fileName}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t('subscriptions.closeAria')}
className="px-2 py-1 text-slate-500 hover:text-slate-800 rounded hover:bg-surface-2"
>
×
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4">
{note.isLoading && <p className="text-[13px] text-slate-400">{t('subscriptions.loading')}</p>}
{note.isError && <p className="text-[13px] text-red-500">{t('subscriptions.noteLoadFailed')}</p>}
{note.data && note.data.body
? <MarkdownText text={note.data.body} />
: note.data && <p className="text-[13px] text-slate-400 italic">{t('subscriptions.noteEmpty')}</p>}
</div>
</div>
</div>
);
}
function ModeSelect({
mode,
onChange,
}: {
mode: 'search' | 'inject';
onChange: (m: 'search' | 'inject') => void;
}) {
return (
<select
className="border border-hairline rounded text-2xs px-1 py-0.5 bg-canvas focus:outline-none focus:ring-1 focus:ring-accent"
value={mode}
onChange={(e) => onChange(e.target.value as 'search' | 'inject')}
>
<option value="search">search</option>
<option value="inject">inject</option>
</select>
);
}
export function SubscriptionsPanel({ currentUserId }: { currentUserId: string }) {
const { t } = useTranslation('userfolder');
const qc = useQueryClient();
const [q, setQ] = useState('');
// Track which (owner, folder) rows are expanded to show the notes list inline.
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (key: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
// Open-modal target for note content preview.
const [openNote, setOpenNote] = useState<{ ownerId: string; folder: string; fileName: string } | null>(null);
const subs = useQuery<{ rows: Subscription[] }>({
queryKey: ['notes-subscriptions'],
queryFn: async () => {
const r = await fetch('/api/notes/subscriptions', { credentials: 'include' });
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 30_000,
});
const discover = useQuery<{ rows: DiscoverRow[] }>({
queryKey: ['notes-discover', q],
queryFn: async () => {
const r = await fetch(`/api/notes/discover?q=${encodeURIComponent(q)}`, {
credentials: 'include',
});
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 15_000,
});
const preview = useQuery<InjectPreview>({
queryKey: ['notes-inject-preview'],
queryFn: async () => {
const r = await fetch('/api/notes/inject-preview', { credentials: 'include' });
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
},
staleTime: 30_000,
});
const subscribe = useMutation({
mutationFn: async ({
publisher,
folder,
mode,
}: {
publisher: string;
folder: string;
mode: 'search' | 'inject';
}) => {
const r = await fetch('/api/notes/subscriptions', {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publisher_user_id: publisher, folder, mode, enabled: true }),
});
if (!r.ok) {
const j = await r.json().catch(() => ({ error: 'failed' }));
throw new Error((j as { error?: string }).error ?? 'failed');
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notes-subscriptions'] });
qc.invalidateQueries({ queryKey: ['notes-inject-preview'] });
qc.invalidateQueries({ queryKey: ['notes-discover'] });
},
});
const unsubscribe = useMutation({
mutationFn: async ({ publisher, folder }: { publisher: string; folder: string }) => {
const r = await fetch(
`/api/notes/subscriptions?publisher_user_id=${encodeURIComponent(publisher)}&folder=${encodeURIComponent(folder)}`,
{ method: 'DELETE', credentials: 'include' },
);
if (!r.ok) throw new Error(`${r.status}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notes-subscriptions'] });
qc.invalidateQueries({ queryKey: ['notes-inject-preview'] });
},
});
// Group discover rows by (owner_id, folder)
const folderGroups = new Map<
string,
{ owner_id: string; folder: string; count: number; visibility: string }
>();
for (const row of discover.data?.rows ?? []) {
const key = `${row.owner_id}/${row.folder}`;
const existing = folderGroups.get(key);
if (existing) {
existing.count++;
} else {
folderGroups.set(key, {
owner_id: row.owner_id,
folder: row.folder,
count: 1,
visibility: row.visibility,
});
}
}
const myFolders = (subs.data?.rows ?? []).filter(
(s) => s.publisher_user_id === currentUserId,
);
const otherSubs = (subs.data?.rows ?? []).filter(
(s) => s.publisher_user_id !== currentUserId,
);
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-6 space-y-8">
{/* My Folders */}
<section>
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">
{t('subscriptions.myFolders')}{' '}
<span className="text-slate-400 font-normal">({myFolders.length})</span>
</h3>
{subs.isLoading && (
<p className="text-[13px] text-slate-400">{t('subscriptions.loading')}</p>
)}
{subs.isError && (
<p className="text-[13px] text-red-500">{t('subscriptions.subsLoadFailed')}</p>
)}
{!subs.isLoading && !subs.isError && myFolders.length === 0 && (
<p className="text-[13px] text-slate-400">{t('subscriptions.noFolders')}</p>
)}
<ul className="space-y-1">
{myFolders.map((s) => {
const key = `${s.publisher_user_id}/${s.folder}`;
const isOpen = expanded.has(key);
return (
<li
key={key}
className="rounded-md bg-surface-2/40 border border-hairline overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => toggleExpanded(key)}
aria-label={isOpen ? t('subscriptions.collapse') : t('subscriptions.expand')}
className="text-slate-500 hover:text-slate-800 text-2xs w-4"
>
{isOpen ? '▼' : '▶'}
</button>
<span className="flex-1 text-[13px] font-mono text-slate-700">{s.folder}</span>
<ModeSelect
mode={s.mode}
onChange={(m) =>
subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m })
}
/>
</div>
{isOpen && (
<NotesListExpanded
ownerId={s.publisher_user_id}
folder={s.folder}
onSelectNote={(fileName) =>
setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName })
}
/>
)}
</li>
);
})}
</ul>
</section>
{/* My Subscriptions */}
<section>
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">
{t('subscriptions.mySubscriptions')}{' '}
<span className="text-slate-400 font-normal">({otherSubs.length})</span>
</h3>
{!subs.isLoading && !subs.isError && otherSubs.length === 0 && (
<p className="text-[13px] text-slate-400">{t('subscriptions.noSubscriptions')}</p>
)}
<ul className="space-y-1">
{otherSubs.map((s) => {
const key = `${s.publisher_user_id}/${s.folder}`;
const isOpen = expanded.has(key);
return (
<li
key={key}
className="rounded-md bg-surface-2/40 border border-hairline overflow-hidden"
>
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => toggleExpanded(key)}
aria-label={isOpen ? t('subscriptions.collapse') : t('subscriptions.expand')}
className="text-slate-500 hover:text-slate-800 text-2xs w-4"
>
{isOpen ? '▼' : '▶'}
</button>
<span className="flex-1 text-[13px] font-mono text-slate-700">
{s.publisher_user_id}/{s.folder}
</span>
<ModeSelect
mode={s.mode}
onChange={(m) =>
subscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder, mode: m })
}
/>
<button
type="button"
className="text-2xs text-red-600 hover:text-red-800 dark:hover:text-red-300 font-medium px-2 py-0.5 rounded hover:bg-red-50 dark:hover:bg-red-500/15 transition-colors"
onClick={() =>
unsubscribe.mutate({ publisher: s.publisher_user_id, folder: s.folder })
}
disabled={unsubscribe.isPending}
>
Unsubscribe
</button>
</div>
{isOpen && (
<NotesListExpanded
ownerId={s.publisher_user_id}
folder={s.folder}
onSelectNote={(fileName) =>
setOpenNote({ ownerId: s.publisher_user_id, folder: s.folder, fileName })
}
/>
)}
</li>
);
})}
</ul>
{unsubscribe.isError && (
<p className="mt-1 text-2xs text-red-600">{(unsubscribe.error as Error).message}</p>
)}
</section>
{/* Discover */}
<section>
<h3 className="text-[13px] font-semibold text-slate-800 mb-2">{t('subscriptions.discover')}</h3>
<input
className="border border-hairline rounded px-2 py-1.5 mb-3 w-full text-[13px] bg-canvas focus:outline-none focus:ring-1 focus:ring-accent placeholder:text-slate-400"
placeholder={t('subscriptions.searchPlaceholder')}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{discover.isLoading && (
<p className="text-[13px] text-slate-400">{t('subscriptions.searching')}</p>
)}
{discover.isError && (
<p className="text-[13px] text-red-500">{t('subscriptions.searchFailed')}</p>
)}
{!discover.isLoading && !discover.isError && folderGroups.size === 0 && (
<p className="text-[13px] text-slate-400">
{q ? t('subscriptions.noResults') : t('subscriptions.searchPrompt')}
</p>
)}
<ul className="space-y-2">
{Array.from(folderGroups.values()).map((g) => {
const alreadySubbed = (subs.data?.rows ?? []).some(
(s) => s.publisher_user_id === g.owner_id && s.folder === g.folder,
);
return (
<li
key={`${g.owner_id}/${g.folder}`}
className="border border-hairline rounded-md px-3 py-2 bg-surface-2/30"
>
<div className="flex items-center gap-2 mb-1.5">
<span className="flex-1 text-[13px] font-mono text-slate-700">
{g.owner_id}/{g.folder}
</span>
<span className="text-2xs text-slate-400">
{t('subscriptions.notesCount', { visibility: g.visibility, count: g.count })}
</span>
</div>
{alreadySubbed ? (
<span className="text-2xs text-slate-400 italic">{t('subscriptions.subscribed')}</span>
) : (
<div className="flex gap-2">
<button
type="button"
className="text-2xs bg-surface-2 border border-hairline px-2 py-0.5 rounded hover:bg-slate-100 transition-colors disabled:opacity-50"
disabled={subscribe.isPending}
onClick={() =>
subscribe.mutate({ publisher: g.owner_id, folder: g.folder, mode: 'search' })
}
>
Subscribe (search)
</button>
<button
type="button"
className="text-2xs bg-surface-2 border border-hairline px-2 py-0.5 rounded hover:bg-slate-100 transition-colors disabled:opacity-50"
disabled={subscribe.isPending}
onClick={() =>
subscribe.mutate({ publisher: g.owner_id, folder: g.folder, mode: 'inject' })
}
>
Subscribe (inject)
</button>
</div>
)}
</li>
);
})}
</ul>
{subscribe.isError && (
<p className="mt-1 text-2xs text-red-600">{(subscribe.error as Error).message}</p>
)}
</section>
{/* Inject Preview */}
<section>
<h3 className="text-[13px] font-semibold text-slate-800 mb-1">{t('subscriptions.injectPreview')}</h3>
<p className="text-2xs text-slate-500 mb-2">
{t('subscriptions.injectPreviewDesc')}
</p>
{preview.isLoading && (
<p className="text-[13px] text-slate-400">{t('subscriptions.loading')}</p>
)}
{preview.isError && (
<p className="text-[13px] text-red-500">{t('subscriptions.injectLoadFailed')}</p>
)}
{!preview.isLoading && !preview.isError && (
<>
{(preview.data?.items ?? []).length === 0 ? (
<p className="text-[13px] text-slate-400">{t('subscriptions.noInjectNotes')}</p>
) : (
<ul className="space-y-0.5 mb-2">
{(preview.data?.items ?? []).map((it) => (
<li
key={`${it.owner_id}/${it.folder}/${it.file_name}`}
className="flex items-center gap-2 text-[13px] font-mono text-slate-700"
>
<span className="flex-1">{it.owner_id}/{it.folder}/{it.file_name}</span>
<span className="text-slate-400 text-2xs">{it.size_kb} KB</span>
</li>
))}
</ul>
)}
<div className="text-2xs text-slate-500 pt-1 border-t border-hairline">
Total:{' '}
<span className="font-semibold text-slate-700">{preview.data?.total_kb ?? 0} KB</span>
{' '}/{' '}
<span className="font-semibold text-slate-700">{preview.data?.budget_kb ?? 0} KB</span>
{' '}budget
</div>
</>
)}
</section>
</div>
{openNote && (
<NoteContentModal
ownerId={openNote.ownerId}
folder={openNote.folder}
fileName={openNote.fileName}
onClose={() => setOpenNote(null)}
/>
)}
</div>
);
}
@@ -1,460 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueries, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileTree, type SubdirId, type FileEntry, FILE_SUBDIRS } from './FileTree';
import { MonacoFileEditor } from './MonacoFileEditor';
import { SaveAsScriptDialog } from './SaveAsScriptDialog';
import { ScriptDiffReview } from './ScriptDiffReview';
import { BrowserSessionsPanel } from './BrowserSessionsPanel';
import { McpPanel } from './McpPanel';
import { AgentsMdPanel } from './AgentsMdPanel';
import { NewFileForm } from './NewFileForm';
import { PetsPanel } from './PetsPanel';
import { SshConnectionsPanel } from './SshConnectionsPanel';
import { NotesPanel } from './NotesPanel';
import { SubscriptionsPanel } from './SubscriptionsPanel';
import { SkillsPanel } from './SkillsPanel';
import { MemoryPanel } from './MemoryPanel';
/** All subdirs shown in the tree — both real file-based and virtual. */
const ALL_SUBDIRS: SubdirId[] = ['agents-md', 'browser-macros', 'recordings', 'notes', 'subscribed-notes', 'pets', 'browser-sessions', 'mcp', 'skills', 'ssh-connections', 'trash', 'memory'];
// title/desc/agency are i18n keys under root.subdirs.<key>, translated at render.
const SUBDIR_INFO: { id: SubdirId; icon: string; key: string }[] = [
{ id: 'agents-md', icon: '📖', key: 'agentsMd' },
{ id: 'browser-macros', icon: '🤖', key: 'browserMacros' },
{ id: 'recordings', icon: '🎬', key: 'recordings' },
{ id: 'pets', icon: '◉', key: 'pets' },
{ id: 'browser-sessions', icon: '🌐', key: 'browserSessions' },
{ id: 'trash', icon: '🗑', key: 'trash' },
{ id: 'memory', icon: '🧠', key: 'memory' },
{ id: 'mcp', icon: '🔌', key: 'mcp' },
{ id: 'skills', icon: '📚', key: 'skills' },
{ id: 'ssh-connections', icon: '🔐', key: 'sshConnections' },
{ id: 'notes', icon: '📝', key: 'notes' },
{ id: 'subscribed-notes', icon: '🔔', key: 'subscribedNotes' },
];
interface FolderListResponse {
files: FileEntry[];
}
async function apiFolderList(subdir: SubdirId): Promise<FileEntry[]> {
const res = await fetch(`/api/users/me/folder/list?subdir=${subdir}`, {
credentials: 'include',
});
if (!res.ok) throw new Error(`List failed: ${res.status}`);
const data: FolderListResponse = await res.json();
return data.files ?? [];
}
interface NoteDiscoverRow {
folder: string;
file_name: string;
updated_at: number;
content_size: number;
}
/** Fetch all own notes via the discover API (unlimited depth, returns folder/file pairs). */
async function apiNotesList(): Promise<FileEntry[]> {
const res = await fetch('/api/notes/discover?owner_id=me&limit=200', {
credentials: 'include',
});
if (!res.ok) throw new Error(`Notes list failed: ${res.status}`);
const data: { rows: NoteDiscoverRow[] } = await res.json();
return (data.rows ?? []).map((r) => ({
// Use "folder/file.md" as the virtual file name so FileTree shows the full path
name: `${r.folder}/${r.file_name}`,
size: r.content_size,
mtime: new Date(r.updated_at).toISOString(),
}));
}
async function apiFolderGet(subdir: SubdirId, path: string): Promise<string> {
const res = await fetch(
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
{ credentials: 'include' },
);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
return res.text();
}
async function apiFolderPut(subdir: SubdirId, path: string, body: string): Promise<void> {
const res = await fetch(
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
{
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body,
},
);
if (!res.ok) throw new Error(`Save failed: ${res.status}`);
}
async function apiFolderDelete(subdir: SubdirId, path: string): Promise<void> {
const res = await fetch(
`/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`,
{ method: 'DELETE', credentials: 'include' },
);
if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
}
/** Virtual subdirs don't have real files on disk */
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes', 'memory']);
/** Subdirs where users can create new files from the UI */
const WRITABLE_USER_SUBDIRS = new Set<SubdirId>(['browser-macros']);
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
interface UserFolderTabProps {
showToast?: ShowToast;
}
export function UserFolderTab({ showToast }: UserFolderTabProps = {}) {
const { t } = useTranslation('userfolder');
const [selectedSubdir, setSelectedSubdir] = useState<SubdirId | null>('browser-macros');
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [editorDirty, setEditorDirty] = useState(false);
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
const qc = useQueryClient();
// Fetch the current user (needed for SubscriptionsPanel)
const meQuery = useQuery<{ id: string }>({
queryKey: ['auth', 'me'],
queryFn: async () => {
const res = await fetch('/api/auth/me');
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
},
staleTime: 60_000,
});
const currentUserId = meQuery.data?.id ?? '';
// Only file-based subdirs are fetched; notes uses a separate discover endpoint
// because notes live at depth 2 (notes/<folder>/<file>.md) and folder/list only shows depth 1.
const fileSubdirs = FILE_SUBDIRS.filter((s) => s !== 'notes');
const subdirResults = useQueries({
queries: fileSubdirs.map(subdir => ({
queryKey: ['userfolder', 'list', subdir],
queryFn: () => apiFolderList(subdir),
staleTime: 10_000,
})),
});
// Separate query for notes that uses the discover API instead of the folder-list API
const notesListQuery = useQuery<FileEntry[]>({
queryKey: ['userfolder', 'list', 'notes'],
queryFn: () => apiNotesList(),
staleTime: 10_000,
});
const subdirFilesMap: Partial<Record<SubdirId, { subdir: SubdirId; files: FileEntry[]; loading: boolean }>> = Object.fromEntries(
fileSubdirs.map((subdir, i) => [
subdir,
{
subdir,
files: subdirResults[i]!.data ?? [],
loading: subdirResults[i]!.isLoading,
},
])
);
// Inject notes separately using the discover-based listing (depth-2 aware)
subdirFilesMap['notes'] = {
subdir: 'notes',
files: notesListQuery.data ?? [],
loading: notesListQuery.isLoading,
};
// Build the tree data: real subdirs get files, virtual ones get empty placeholders
const SUBDIRS = ALL_SUBDIRS;
const subdirQueries = SUBDIRS.map(subdir => {
if (VIRTUAL_SUBDIRS.has(subdir)) {
return { subdir, files: [], loading: false };
}
return subdirFilesMap[subdir] ?? { subdir, files: [], loading: false };
});
// File content query — only when a file is selected (and not virtual subdir)
const fileQuery = useQuery<string>({
queryKey: ['userfolder', 'file', selectedSubdir, selectedFile],
queryFn: () => apiFolderGet(selectedSubdir!, selectedFile!),
enabled: !!(selectedSubdir && selectedFile && !VIRTUAL_SUBDIRS.has(selectedSubdir)),
staleTime: 30_000,
refetchOnWindowFocus: false,
});
const deleteMutation = useMutation({
mutationFn: ({ subdir, file }: { subdir: SubdirId; file: string }) =>
apiFolderDelete(subdir, file),
onSuccess: (_data, { subdir, file }) => {
qc.invalidateQueries({ queryKey: ['userfolder', 'list', subdir] });
if (selectedSubdir === subdir && selectedFile === file) {
setSelectedFile(null);
}
},
onError: (err, { subdir, file }) => {
const msg = err instanceof Error ? err.message : 'Unknown error';
const label = t('delete.failed', { path: `${subdir}/${file}` });
if (showToast) showToast(`${label}: ${msg}`, 'error');
else console.error(`${label}: ${msg}`);
},
});
const selectedSubdirData = subdirQueries.find(q => q.subdir === selectedSubdir);
const selectedFileMeta = selectedSubdirData?.files.find(f => f.name === selectedFile);
const handleSave = async (content: string) => {
if (!selectedSubdir || !selectedFile) return;
await apiFolderPut(selectedSubdir, selectedFile, content);
qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] });
qc.setQueryData(
['userfolder', 'file', selectedSubdir, selectedFile],
content,
);
};
const handleDelete = (subdir: SubdirId, file: string) => {
if (!window.confirm(`Delete ${subdir}/${file}?`)) return;
deleteMutation.mutate({ subdir, file });
};
function handleSelectSubdir(subdir: SubdirId) {
if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return;
if (selectedSubdir === subdir) {
setSelectedSubdir(null);
setSelectedFile(null);
} else {
setSelectedSubdir(subdir);
setSelectedFile(null);
}
}
function handleSelectFile(subdir: SubdirId, file: string) {
if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return;
setSelectedSubdir(subdir);
setSelectedFile(file);
}
// Determine right-pane content
const isVirtualSelected = selectedSubdir !== null && VIRTUAL_SUBDIRS.has(selectedSubdir);
return (
<div className="flex h-full gap-2 p-2 overflow-hidden">
{/* Left: file tree */}
<div
className="bg-canvas border border-hairline rounded-md overflow-hidden flex flex-col"
style={{ width: 'clamp(200px, 22vw, 280px)', flexShrink: 0 }}
>
<div className="flex-shrink-0 px-3 py-2.5 border-b border-hairline">
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
User Folder
</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<FileTree
subdirData={subdirQueries}
selectedSubdir={selectedSubdir}
selectedFile={selectedFile}
onSelectSubdir={handleSelectSubdir}
onSelectFile={handleSelectFile}
onDeleteFile={handleDelete}
/>
</div>
</div>
{/* Right: editor / virtual panel */}
<div className="flex-1 min-w-0 bg-canvas border border-hairline rounded-md overflow-hidden flex flex-col">
{/* agents-md virtual pane */}
{isVirtualSelected && selectedSubdir === 'agents-md' && (
<AgentsMdPanel onDirtyChange={setEditorDirty} />
)}
{/* browser-sessions virtual pane */}
{isVirtualSelected && selectedSubdir === 'browser-sessions' && (
<BrowserSessionsPanel />
)}
{/* mcp virtual pane */}
{isVirtualSelected && selectedSubdir === 'mcp' && (
<McpPanel showToast={showToast} />
)}
{/* skills virtual pane */}
{isVirtualSelected && selectedSubdir === 'skills' && (
<SkillsPanel />
)}
{/* memory virtual pane */}
{isVirtualSelected && selectedSubdir === 'memory' && (
<MemoryPanel />
)}
{/* pets virtual pane */}
{isVirtualSelected && selectedSubdir === 'pets' && (
<PetsPanel showToast={showToast} />
)}
{/* ssh-connections virtual pane */}
{isVirtualSelected && selectedSubdir === 'ssh-connections' && (
<SshConnectionsPanel showToast={showToast} />
)}
{/* subscribed-notes virtual pane */}
{isVirtualSelected && selectedSubdir === 'subscribed-notes' && (
<SubscriptionsPanel currentUserId={currentUserId} />
)}
{/* notes/ pane — uses discover API for listing (depth 2) + NotesPanel editor */}
{selectedSubdir === 'notes' && (
<NotesPanel
filePath={selectedFile}
onSaved={() => {
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] });
}}
onSelectFile={(path) => {
setSelectedFile(path);
}}
/>
)}
{/* File-based content (non-notes subdirs) */}
{!isVirtualSelected && selectedSubdir !== 'notes' && (
<>
{/* Save as Script toolbar — shown only in recordings/ when a .json file is selected */}
{selectedSubdir === 'recordings' && selectedFile?.endsWith('.json') && (
<div className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border-b border-hairline bg-surface-2/50">
<span className="text-2xs text-slate-500 flex-1">
Recording: <span className="font-mono">{selectedFile}</span>
</span>
<button
type="button"
onClick={() => setSaveAsDialogOpen(true)}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
>
Save as Script
</button>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{selectedSubdir && selectedFile ? (
/* If a .next.js patch file is selected in browser-macros/, show the diff review pane */
selectedSubdir === 'browser-macros' && selectedFile.endsWith('.next.js') ? (
<ScriptDiffReview
scriptName={selectedFile.slice(0, -'.next.js'.length)}
showToast={showToast}
onClose={(acceptedScript) => {
if (acceptedScript) {
setSelectedFile(acceptedScript);
} else {
setSelectedFile(null);
}
}}
/>
) : fileQuery.isLoading ? (
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
Loading
</div>
) : fileQuery.isError ? (
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
Failed to load file.
</div>
) : (
<MonacoFileEditor
subdir={selectedSubdir}
filename={selectedFile}
content={fileQuery.data ?? ''}
mtime={selectedFileMeta?.mtime ?? ''}
size={selectedFileMeta?.size ?? 0}
onSave={handleSave}
onDirtyChange={setEditorDirty}
/>
)
) : (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
{selectedSubdir && WRITABLE_USER_SUBDIRS.has(selectedSubdir) ? (
/* Focused view for a selected writable subdir: info + new-file form */
(() => {
const info = SUBDIR_INFO.find(i => i.id === selectedSubdir);
if (!info) return null;
const files = selectedSubdirData?.files ?? [];
return (
<>
<div className="mb-6 flex gap-3">
<span className="text-2xl leading-none mt-0.5 select-none" aria-hidden>
{info.icon}
</span>
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-slate-900">{t(`root.subdirs.${info.key}.title`)}</h2>
<p className="text-[13px] text-slate-500 mt-1 leading-relaxed">{t(`root.subdirs.${info.key}.desc`)}</p>
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{t(`root.subdirs.${info.key}.agency`)}</p>
</div>
</div>
{files.length > 0 && (
<div className="mb-4 text-xs text-slate-500">
{t('root.fileCount', { count: files.length })}
</div>
)}
<NewFileForm
subdir={selectedSubdir as 'browser-macros'}
existingFilenames={files.map(f => f.name)}
onCreate={async (filename, skeleton) => {
await apiFolderPut(selectedSubdir, filename, skeleton);
qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] });
setSelectedFile(filename);
}}
/>
</>
);
})()
) : (
/* Full overview when no subdir is selected (or non-writable subdir selected without a file) */
<>
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('root.title')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
{t('root.intro')}
</p>
</div>
<ul className="space-y-5">
{SUBDIR_INFO.map(({ id, icon, key }) => (
<li key={id} className="flex gap-3">
<span className="text-xl leading-none mt-0.5 select-none" aria-hidden>
{icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-slate-900">{t(`root.subdirs.${key}.title`)}</div>
<p className="text-[13px] text-slate-600 mt-1 leading-relaxed">{t(`root.subdirs.${key}.desc`)}</p>
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{t(`root.subdirs.${key}.agency`)}</p>
</div>
</li>
))}
</ul>
</>
)}
</div>
</div>
)}
</div>
</>
)}
</div>
{/* Save as Script dialog — navigates to browser-macros on success */}
{saveAsDialogOpen && selectedFile?.endsWith('.json') && (
<SaveAsScriptDialog
recordingName={selectedFile.endsWith('.json') ? selectedFile.slice(0, -5) : selectedFile}
onClose={() => setSaveAsDialogOpen(false)}
onSuccess={(scriptName) => {
setSaveAsDialogOpen(false);
// Navigate to the new macro in browser-macros/
setSelectedSubdir('browser-macros');
setSelectedFile(scriptName);
}}
/>
)}
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
---
id: changelog
title: 更新履歴(新着情報)
category: basic
order: 5
keywords: [更新履歴, 新着情報, What's New, リリースノート, 変更点, アップデート, changelog]
---
# 更新履歴(新着情報)
MAESTRO に入った、ユーザーに関係する主な変更を新しい順に並べています。「最近なにが変わったのか」を確認したいときに開いてください。細かな不具合修正や内部改善は省いています。
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
## 2026-06-23 — 右側パネルをワーカー / ノード(GPU)表示に集約
右側の情報パネルにあった自由ウィジェット機能(Markdown メモなどをタブで増やせる仕組み)を廃止しました。パネルは「ワーカー」と「ノード(GPU)」の 2 つの固定タブだけになり、稼働状況をすぐ確認できます。ウィジェットを書き込むエージェント用ツール(UpdateDashboardWidget)も同時に削除しました。これまで作っていた Markdown ウィジェットの内容は表示されなくなります。
## 2026-06-23 — 最初のメッセージに付けた添付ファイルの扱いを修正
タスクを新規作成するときに付けた添付ファイルが、チャットに表示されず、エージェントにも「どのファイルが届いたか」が伝わっていませんでした。あとからコメントで添付したときと挙動が違っていた問題を直しました。新規作成時の添付も、チャットにダウンロードリンクが並び、エージェントへ `input/` 内のファイル名が渡るようになりました(→[タスクを作って実行する](./02-tasks.md))。
## 2026-06-23 — カレンダーを見やすく(絞り込み・複数日予定・スマホ上下分割)
カレンダーをより使いやすくしました。上部のボタンで「タスク / 変更ファイル / 予定」を絞り込めるようになり、見たい種類だけを表示できます(絞り込みはこの端末に記憶されます)。予定には終了日を設定でき、複数日にまたがる予定は月表示で横棒としてつながって見えます。スマートフォンでは、上に月のカレンダー、下にその日の詳細、という上下 2 分割で表示されます(→[カレンダー](./22-calendar.md))。
## 2026-06-23 — HTTPS をオフにしてもリダイレクトが止まらない不具合を修正
HTTPS と HTTP→HTTPS リダイレクトを一度オンにすると、設定でオフにしてもブラウザがずっと HTTPS にリダイレクトし続ける不具合を修正しました。原因はブラウザに焼き付く **HSTS** という仕組みです。今回から HSTS は既定オフになり、**オフの間はサーバが固定を解除する信号を送る**ため、HTTPS を一度開けば固定が解除されて平文 HTTP に戻れます。あわせてリダイレクトを一時リダイレクト(302)に変え、オフにすれば確実に止まるようにしました。正式な証明書を入れて意図的に HTTPS を固定したい場合は、設定の「HSTS を送出」をオンにできます(→[システム設定](./17-settings.md))。
## 2026-06-23 — ワークスペース一覧に「実行中」件数を表示
左のワークスペース一覧で、各ワークスペースにいま実行中のタスクが何件あるかを緑のバッジで確認できるようになりました(実行中が 1 件以上あるときだけ表示)。件数は自動で更新されます(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-23 — ワークスペースのチャットを検索・絞り込み
ワークスペースの「チャット」一覧に、タスク一覧ページと同じ検索・絞り込みバーが付きました。キーワード(タイトル・本文・Piece・作成者名)での検索、状態(実行中・待機・失敗など)でのタブ絞り込み、並び替え(更新順・状態・タイトル)ができます。チャットが増えたワークスペースでも目的の会話をすぐ見つけられます。
## 2026-06-23 — 新規ワークスペースの初期 AGENTS.md を充実
ワークスペースを新しく作ったときに置かれる AGENTS.md(エージェントへの指示書)の初期内容が、空に近い雛形から「使い始めてすぐ賢く振る舞う」ための足場に変わりました。予定をカレンダーに登録する、覚えておくべきことをメモリに保存する、HTML 資料はスキルを使って作る、GUI が分かりやすいときはワークスペース・アプリにする、そして使い方が固まったらこの AGENTS.md 自体を育てる、といった方針を最初から含みます。中身は自由に書き換えられます(→[ワークスペースとメンバー](./21-workspaces.md))。
## 2026-06-23 — ワークスペース専用のスケジュール
ワークスペースに「スケジュール」タブが付き、そのワークスペース専用の定期実行を登録できるようになりました。スケジュールはワークスペースの資産として扱われ、作成者が抜けても残ったメンバーが運用(停止・再開・削除など)を引き継げます。ただし「何を実行するか」の中身を変えられるのは作成者本人だけです(→[スケジュールで自動実行](./06-schedules.md))。
## 2026-06-22 — 接続情報をワークスペースで安全に共有
案件ワークスペースで登録した接続情報を、メンバーで安全に共有できるようになりました。
- **SSH 接続・ブラウザセッション・API key 方式の MCP サーバー** を、ワークスペース単位で共有。資格情報はワークスペースの鍵で暗号化され、メンバーは接続を使えても token や秘密鍵の中身は見えません
- API key 方式の MCP サーバーは、オーナー・管理者が **設定 → MCP** からメンバーの利用可否を管理できます
- 案件ワークスペースの **自動学習(Reflection** が、個人ではなくワークスペースに紐づくようになりました
- くわしくは [ワークスペースとメンバー](./21-workspaces.md) ・ [MCP サーバー連携](./13-mcp.md) ・ [SSH リモート操作](./14-ssh.md)
## 2026-06-19 — ワークスペースまわりの大型刷新
「ユーザーフォルダ」やバラバラだった機能を **ワークスペース** に集約し、画面を一新しました。
- **カレンダー** タブを追加。ワークスペースの活動(タスク・予定・変更ファイル)を日付ごとに振り返れます([カレンダー](./22-calendar.md)
- **ワークスペース招待リンク** を追加。組織に関係なく、リンクを渡してメンバーを招待できます([ワークスペースとメンバー](./21-workspaces.md)
- **ワークスペース・アプリ** を追加。エージェントが作った HTML の小さなアプリを、サンドボックス内でファイル入出力付きで動かせます([ワークスペース・アプリ](./20-workspace-apps.md)
- **タスクページにファイル / 設定サブタブ** を追加。ページを離れずに個人ワークスペースのファイルや設定を扱えます([タスクを作って実行する](./02-tasks.md)
- **ファイルタブをエクスプローラ風に刷新**。ドラッグ&ドロップのアップロード、複数選択でのダウンロード(zip)・削除に対応
- **ソースライブラリ**: エージェントが調べ物で取得した資料を `source/` に出典付きで蓄積し、ファイル一覧から開けます
- メモリ・スキル・Pieces・MCP・SSH・ブラウザを **ワークスペース単位** に統合。独立していた「ユーザーフォルダ」タブは廃止しました
- タスクの公開範囲を簡素化(ワークスペース内のチャットはメンバー全員に公開)
## 2026-06-18 — 共有ワークスペースの土台を導入
案件やチームで共有する **ワークスペース** の基盤が入りました。ワークスペースごとのフォルダ設定(AGENTS.md / メモリ / Pieces / スキル / MCP / SSH)と、スマートフォン対応のレイアウトを整えています。メンバー管理や招待リンクなどの共有まわりは、翌日(6/19)の刷新でそろいました。
## 2026-06-17 — タスク作成・実行まわりの改善
- **プロンプト改善コーチ**: 作成ダイアログで依頼文をその場で評価し、改善案を提示
- **実行中の TIPS 表示**: エージェントの待ち時間に、使い方のヒントをローテーション表示
- **コメントの添付ファイル**: 追加指示に付けたファイルをチップ+ダウンロードリンクで表示
- **ブラウザベースの初期セットアップウィザード**: LLM 接続・ポート・認証を画面の案内に沿って設定
## それ以前
- **LLM 使用量(Usage)タブ**: Gateway 経由と Direct を合算したトークン使用量を日次で可視化([LLM Gateway 連携](./15-llm-gateway.md)
- **ローカル認証と組織**: Google / Gitea に加え、ユーザー名+パスワードでのログイン・サインアップ承認制と、ローカル組織によるメンバー管理に対応([ユーザー管理 / 安全性](./19-admin.md)
- **ヘルプの全面刷新**: 検索・ディープリンク付きのヘルプに作り直しました(いま見ているこの画面です)
- **製品名を MAESTRO に変更**
+17
View File
@@ -52,6 +52,23 @@ MAESTRO に頼める代表的な仕事です。
エージェントが実際に呼べるツールの一覧は [ツール一覧](./16-tools.md) を参照してください。
## 画面の構成(ナビゲーション)
上部のタブで主な画面を切り替えます。
| タブ | 何をする場所か |
|---|---|
| タスク | 個人ワークスペースでタスクを作り、実行を見る(既定の入口) |
| ワークスペース | 個人 / 案件のワークスペースを切り替え、メンバー・ファイル・設定を管理する(→[ワークスペースとメンバー](./21-workspaces.md) |
| カレンダー | ワークスペースの活動を日付ごとに振り返る(タスク・予定・変更ファイル)(→[カレンダー](./22-calendar.md) |
| スケジュール | 定期実行を登録・管理する(→[スケジュール実行](./06-schedules.md) |
| Pieces | piece(タスクの型)の一覧・作成(→[piece を使う・作る](./05-pieces.md) |
| 使用量 | LLM のトークン使用量を日次で確認する(→[LLM Gateway 連携](./15-llm-gateway.md) |
| 設定 | アプリの挙動・個人設定(→[システム設定](./17-settings.md) |
| ヘルプ | このヘルプ。一覧の先頭にある「更新履歴」で最近の変更点をたどれます(→[更新履歴](./00-changelog.md) |
「ユーザー」「CAPTCHA」タブは管理者にだけ表示されます。
## はじめての一歩
1. **タスクを作る** — 依頼内容を入力して実行します。書き方のコツは [タスクを作って実行する](./02-tasks.md) を参照
+48 -1
View File
@@ -3,9 +3,21 @@ id: tasks
title: タスクを作って実行する
category: basic
order: 20
keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask policy]
keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask policy, ワークスペース, 共有, メンバー, 招待, 組織, サブタブ, ファイル, 設定, 個人ワークスペース]
---
## タスクページのサブタブ
タスクページの上部には **タスク / ファイル / 設定** の 3 つのサブタブがあります。どれも自分の **個人ワークスペース** を対象にしていて、ページを離れずに切り替えられます。
| サブタブ | 内容 |
|---|---|
| タスク | これまでどおりのタスク一覧と詳細(既定の表示) |
| ファイル | 個人ワークスペースのファイル(`input/` `output/` など)を一覧・アップロード・削除 |
| 設定 | 個人ワークスペースの設定(AGENTS.md / メモリ / Pieces / スキル / MCP / SSH / ブラウザ / メンバー) |
「ファイル」「設定」は、**ワークスペース** ページで使うものと同じ画面を個人ワークスペース向けに開いているだけです。ワークスペースの考え方は [ワークスペースとメンバー](./21-workspaces.md) を参照してください。案件ワークスペースの同じタブと操作感はそろえてあります。選んだサブタブは URL に残るので、ブラウザの戻る・進むや URL 共有でも保てます。
## 新しいタスクを作る
タスク一覧の上部にある **「新しい Task」ボタン** を押すと、作成ダイアログが開きます。最低限必要なのは「依頼内容」だけです。
@@ -74,6 +86,41 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
「組織」は Gitea でログインしていて、所属組織がある場合のみ選べます。組織が複数あるときは共有先を選択できます。
## ワークスペースの共有とメンバー
案件などのワークスペースは、招待したメンバーと共有して使います。
メンバー招待は「設定 → メンバー」から行います。候補に出るのは **自分と同じ組織に属するユーザーだけ**です(全ユーザーを一覧には出しません)。所属組織が無い場合は候補が空になります。管理者だけは全ユーザーから選べます。
ワークスペースの中で始めたチャットは、**常にそのワークスペースのメンバー全員に公開**され、メンバー以外には見えません。チャットごとに公開範囲を選ぶ設定はありません(上の「公開範囲」は個人のタスクにだけ適用されます)。誰に見せるかはワークスペースのメンバー構成で決まる、と考えてください。
## ワークスペースの名前変更・削除
ワークスペースのヘッダー(タイトルの並び)に、名前変更と削除のボタンがあります。どちらもオーナーと管理者だけに表示されます。
- **名前変更**: タイトル横の鉛筆ボタンを押すとその場で編集できます。保存すると一覧の表示名もすぐ変わります。
- **削除**: ゴミ箱ボタンを押すと確認が出て、実行するとワークスペースが一覧から消え、ワークスペース一覧に戻ります。個人ワークスペースは既定の場所なので削除できません(案件ワークスペースだけが対象)。
## ファイルの削除
「ファイル」タブのファイルは、行ごとのチェックボックスで複数選択できます。「すべて選択」で一括選択し、「削除」ボタンでまとめて消せます。ファイルにマウスを重ねるとゴミ箱アイコンが出て、1 件だけその場で削除することもできます。削除は確認ダイアログを挟みます。閲覧専用メンバーは削除できません。
## チャット内のファイルタブ
個々のチャット(タスク)を開いたときの「ファイル」タブも、ワークスペースのファイルタブと同じ操作感にそろえました。アイコングリッド表示・パンくず移動・アップロード・複数選択削除が使えます。
このタブには **workspace / input / output / logs** の区分があり、上のボタンで切り替えます。アップロードと削除ができるのは **input と output** だけです。`logs`(実行ログ)と `workspace`(作業ディレクトリ)はエージェントの管理領域なので読み取り専用です。
アップロード・削除はタスクのオーナー(と管理者)だけが行えます。エージェントの実行中はファイルを変更できません(実行が終わってから操作してください)。同名のファイルをアップロードすると、既存を上書きせず `名前 (2).拡張子` のように別名で保存します。
## ファイルのダウンロード
ファイルにマウスを重ねると、タイル右上にダウンロードアイコンが出て、1 件だけその場で保存できます。ダウンロードは閲覧操作なので、編集権の無い閲覧メンバーでも行えます。
複数まとめて保存したいときは、チェックボックスで選択して「ダウンロード」ボタンを押すと、選択したファイルが 1 つの zip(`files.zip`)にまとまって落ちてきます。「削除」の隣にあります。
エージェントが調べ物で取得した資料は、これまでどおり `source/` フォルダに溜まります(取得元の記録 `source/index.jsonl` も残ります)。以前あった「ソース」という専用の一覧表示は廃止しましたが、`source/` は通常のフォルダとしてファイル一覧から開けます。
## 定期実行
「定期実行」にチェックを入れると、毎日 / 毎週 / 毎月 / cron 式 / 一度きり の自動実行を設定できます。詳しくは [スケジュール実行](./06-schedules.md) を参照。
+4 -2
View File
@@ -3,7 +3,7 @@ id: running
title: 実行中のタスクを見る・介入する
category: basic
order: 30
keywords: [チャット, ストリーミング, ツールコール, 割り込み, interjection, ブラウザ, SSH, 進捗]
keywords: [チャット, ストリーミング, ツールコール, 割り込み, interjection, ブラウザ, SSH, 進捗, タブ, 入場エフェクト]
---
タスクが動き出すと、その様子をリアルタイムで観察でき、必要なら途中で口を出せます。
@@ -53,11 +53,13 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
## ブラウザ・SSH の専用タブ
タスクの種類によっては、実行中に専用タブが現れます。
ブラウザ・SSH のタブは常設ではなく、セッションが実際に生きている間だけタブバーに現れます。ブラウザセッションが立ち上がったとき、SSH コンソールに接続したときに、対応するタブが右からスッと滑り込み、一度だけ淡く光って出現を知らせます(見逃さないための入場エフェクト)。動きを抑える設定(OS の「視差効果を減らす」)が有効なときは、アニメーションなしで即座に表示されます。
- **ブラウザ**: エージェントがブラウザ操作を行うと、ライブビュー(noVNC)でその画面をリアルタイムに見られます
- **SSH(コンソール)**: SSH コンソールのセッションが開いているときに表示される端末です。AI と人間が同じ PTY を共有し、双方の入出力をリアルタイムに見られます。詳しくは [SSH 連携](./14-ssh.md) を参照
セッションが終わるとタブは自動的に消え、ほかのタブを開いていた場合は会話に戻ります。
## サブタスク待ち
エージェントが並列の子タスクを起動すると、状態が `waiting_subtasks` になり、子タスクの進捗がまとめて表示されます。仕組みの詳細は [サブタスク](./10-subtasks.md) を参照してください。
+18 -1
View File
@@ -3,7 +3,7 @@ id: results
title: 結果を受け取る
category: basic
order: 40
keywords: [ファイル, output, プレビュー, PDF, 印刷, ダウンロード, フィードバック]
keywords: [ファイル, output, プレビュー, PDF, 印刷, ダウンロード, フィードバック, ワークスペース, チャット, runs, ログ, ソース, 出典, 資料, WebFetch, BrowseWeb, DownloadFile]
---
タスクが完了すると、最終回答に加えて、エージェントが作ったファイルを受け取れます。
@@ -20,6 +20,23 @@ keywords: [ファイル, output, プレビュー, PDF, 印刷, ダウンロー
ファイル名をクリックするとプレビューが開きます。
## ワークスペース内のチャットごとのファイルタブ
ワークスペースの中で開いた各チャットにも、専用の **ファイル** タブが付きます。これはそのチャット 1 件の入出力・実行ログ(logs セクション = `runs/{taskId}`)を追うためのもので、ワークスペース全体で共有する「ファイル」窓(永続ワークスペースの成果物)とは別物です。
- **チャットのファイルタブ**: そのチャットの input / output / logs。実行ごとのログは `runs/{taskId}` 配下に分かれます
- **ワークスペースの共有ファイル**: ワークスペースに置いた成果物・資料。ワークスペース上部の「ファイル」タブから開きます
「このチャットが何を読んで何を作ったか」を見たいときは前者、「ワークスペースに溜めた共有資料」を見たいときは後者を使います。
## ソースライブラリ
ワークスペースの **ファイル** タブの上部には「ソース」グループが出ます。エージェントが調べ物(WebFetch でのページ取得、BrowseWeb でのブラウザ閲覧、DownloadFile でのファイル取得)をすると、取得した資料がワークスペースの `source/` に溜まり、ここに一覧されます。
各エントリには取得元の URL・取得日・サイズが付きます。タイトル部分をクリックすればそのまま中身をプレビューでき、URL チップから元ページを開けます。まだ何も取得していないワークスペースでは「エージェントに調べ物をさせると、取得した資料がここに溜まります」と表示されます。
これは「何を根拠にこの結論になったか」を後から追うための土台です(出典に基づく回答=grounded Q&A は後続)。
## プレビューする
プレビューはファイル形式ごとに最適な表示になります。
+3 -2
View File
@@ -62,10 +62,10 @@ Pieces ページでは「**Default Pieces**」と「**Custom Pieces**」の 2
**Custom Pieces(ユーザー作成)**
- ユーザーが作成した Piece で、自分のユーザーフォルダ配下に保存されます
- ユーザーが作成した Piece で、作業中のワークスペース(個人ワークスペースなら自分専用、案件ワークスペースならメンバー共有)配下に保存されます
- 自分で作った Custom Piece は編集・削除できます
- Custom Piece は **Default Piece と同名にできません**(別名を付けてください)
- タスク実行時、Custom Piece はそのオーナーのユーザーフォルダから読み込まれ、正しく実行されます
- タスク実行時、Custom Piece はそのワークスペースから読み込まれ、正しく実行されます
## カスタム Piece を作る
@@ -87,4 +87,5 @@ Default Piece の行にある `⎘` ボタンをクリックすると「複製
- `instruction`(指示書)は長く書いて構いません。手順・避けるべきこと・終了方法を明示するとエージェントの動きが安定します
- movement の開始時に、その movement の `allowed_tools` と 1 行サマリが自動で system prompt に注入されます。指示書にツール一覧を重複して書く必要はありません
- 必要なツールは `allowed_tools` に列挙します。MCP ツールをまとめて許可するなら `mcp__*` を追加します
- すべての movement で共通して使うツールは、トップレベルの `shared_tools` にまとめて書けます。`shared_tools` のツールは各 movement の `allowed_tools` に自動で合算されるので、movement ごとに同じツールを繰り返す必要がなく、書き忘れも減ります。`edit`Write/Edit の可否)と SSH 接続の許可は従来どおり movement ごとに効くため、`shared_tools` に入れても接続を宣言していない movement では SSH ツールは使えません
- LLM が `allowed_tools` にないツールを呼ぶとエラーで弾かれます。その場合は Piece を編集してツールを追加してください([困ったときは](08-troubleshooting.md) 参照)
+9 -1
View File
@@ -47,7 +47,7 @@ TopBar → スケジュール ページを開き、「新しいスケジュー
## Agent と Script の違い
- **Agent** — 通常のタスクと同じく、選ばれた Piece を LLM が実行します。`auto` を指定すると実行時に分類器が実 Piece を解決します(解決できないときは `chat` にフォールバック)。
- **Script** — ユーザーフォルダの `scripts/` または `browser-macros/` に登録済みのスクリプトを LLM を介さず直接実行します。スクリプト名と、必要なら params(JSON object)を指定します。実行結果は output/ とログに保存されます。Script はユーザー単位の機能のため owner が必要で、管理者が user script を有効化している場合のみ動きます。
- **Script** — ワークスペースの **設定 → ブラウザ** に登録したブラウザマクロを LLM を介さず直接実行します(汎用 Node の `scripts/` は廃止され、定型処理はブラウザマクロまたはスキルに集約されました。→「[個人の資産(ワークスペース設定)](09-userfolder.md)」)。マクロ名と、必要なら params(JSON object)を指定します。実行結果は output/ とログに保存されます。Script はユーザー単位の機能のため owner が必要で、管理者がこの機能を有効化している場合のみ動きます。
## スケジュールの管理
@@ -59,3 +59,11 @@ TopBar → スケジュール ページを開き、「新しいスケジュー
- **削除**
一覧上部のフィルタ(すべて / 有効 / 停止中)と検索で絞り込めます。タスクの作成全般は [タスクを作る](02-tasks.md) を参照してください。
## ワークスペースのスケジュール
ワークスペースを開くと「スケジュール」タブがあり、そのワークスペース専用のスケジュールを登録できます。実行結果はワークスペースの共有ファイルツリーに出力され、メンバー全員が見られます。TopBar のスケジュール ページは全ワークスペース横断の一覧として従来どおり使えます。
ワークスペースのスケジュールは**ワークスペースの資産**です。作成者が異動・退職してワークスペースを離れても消えず、残ったメンバー(オーナー / 管理者 / 編集者)が引き継いで管理できます。
ただし**「何を実行するか」を変えられるのは作成者本人だけ**です。スケジュールは作成者の資格(ブラウザセッション・メモリ・接続情報)で動くため、他のメンバーが本文や Piece、スクリプトを書き換えると作成者になりすました実行になってしまうからです。作成者以外のメンバーができるのは、停止 / 再開・再スケジュール・公開範囲の変更・今すぐ実行・削除といった**運用操作**に限られます。内容を変えたいときは、作成者に依頼するか、自分で新しいスケジュールを作ってください。
+1 -1
View File
@@ -38,7 +38,7 @@ keywords: [トラブル, エラー, 失敗, waiting, スタック, 再試行]
タスク中のブラウザ操作(BrowseWeb)で、保存済みのログイン情報が期限切れになっているとこのメッセージが返ります。
**対処**: 設定 → ユーザーフォルダ → ブラウザセッション で対象セッションを開き、「再ログイン」して保存し直します。詳しくは [ユーザーフォルダ](09-userfolder.md) を参照。ログイン情報(cookie)は時間が経つと失効するため、定期的なメンテナンスが必要です。
**対処**: ワークスペース → 設定 → ブラウザ で対象セッションを開き、「再ログイン」して保存し直します。詳しくは [個人の資産(ワークスペース設定)](09-userfolder.md) を参照。ログイン情報(cookie)は時間が経つと失効するため、定期的なメンテナンスが必要です。
## 成果物が output/ にあるはずなのに見えない
+61 -55
View File
@@ -1,33 +1,59 @@
---
id: userfolder
title: User Folder(自分の資産
title: 個人の資産(ワークスペース設定
category: advanced
order: 90
keywords: [User Folder, AGENTS.md, notes, browser-macros, browser-sessions, recordings]
keywords: [User Folder, ワークスペース設定, AGENTS.md, browser-macros, browser-sessions, recordings, Pets, メモリ, MCP, SSH, スキル, メンバー, 招待リンク, 共有, editor, viewer]
---
# User Folder(自分の資産
# 個人の資産(ワークスペース設定
User Folder は、ユーザーごとに永続化される個人の資産置き場です。エージェントへの恒久指示、共有メモ、ブラウザ自動化、ログイン済みセッションなどがここに集まり、タスクをまたいで「あなた仕様」のエージェントを作り込めます。
以前は独立した **ユーザーフォルダ** タブにまとまっていた個人の資産(恒久指示・メモリ・Pieces・スキル・MCP・SSH・ブラウザ)は、**ワークスペースの設定**に集約されました。専用タブは廃止されています。
TopBar → **ユーザーフォルダ** タブで開きます。左にサブフォルダのツリー、右にファイルエディタ(または専用パネル)という 2 カラム構成です。
個人の資産は「個人ワークスペース」のフォルダに保存され、案件ワークスペースと同じ画面構成で扱えます。
## サブフォルダ一覧
## どこにあるか
| サブフォルダ | 役割 | 編集者 |
|---|---|---|
| AGENTS.md | タスク起動時に system prompt へ注入される恒久指示 | ユーザー |
| notes/ | 共有可能な Markdown メモ(エージェントが検索・参照) | ユーザー |
| browser-macros/ | Playwright ブラウザマクロ | ユーザー / エージェント |
| recordings/ | BrowseWeb の操作トレース(JSON) | エージェント |
| pets/ | Chat 画面に表示するキャラクター | ユーザー |
| browser-sessions/ | 保存済みログインプロファイル(cookie / storage | ユーザー |
| mcp/ | MCP サーバーの登録・接続管理 | ユーザー |
| skills/ | スキル(参照知識・手順書)の管理 | ユーザー |
| ssh-connections/ | SSH 接続定義・暗号鍵 | ユーザー |
| Subscribed Notes | 他ユーザーが公開した notes の購読 | ユーザー |
| trash/ | 削除ファイルの退避先(自動 cleanup) | 自動 |
| memory/ | エージェントの永続事実置き場(閲覧・編集) | エージェント / ユーザー |
| 資産 | 新しい場所 |
|---|---|
| AGENTS.md恒久指示 | ワークスペース → **設定 → AGENTS.md** |
| メモリ | ワークスペース → **設定 → メモリ** |
| Pieces | ワークスペース → **設定 → Pieces** |
| スキル | ワークスペース → **設定 → スキル** |
| MCP サーバー | ワークスペース → **設定 → MCP** |
| SSH 接続 | ワークスペース → **設定 → SSH** |
| ブラウザ(セッション / マクロ / 録画) | ワークスペース → **設定 → ブラウザ** |
| Pets(チャットのキャラクター) | TopBar → **設定 → Pets** |
> **個人ワークスペースと案件ワークスペース**: 個人ワークスペースを開いて設定を編集すると、従来のユーザーフォルダと同じ実体(あなた専用のフォルダ)を操作します。案件ワークスペースの設定は、そのワークスペースのフォルダに保存され、メンバーで共有されます。
## メンバーと招待リンク
案件ワークスペースは複数のメンバーで共有できます。メンバーの追加は **設定 → メンバー** から行います。方法は2つあります。
- **ピッカーから追加**: 同じ組織のユーザーを一覧から選んで追加します。一覧の漏洩を防ぐため、管理者以外には同組織のユーザーしか出ません。Google ログインのユーザーは、管理者がローカル組織を割り当てるまで組織を持たないため、ピッカーが空になることがあります。
- **招待リンク**: リンクを知っている相手を組織に関係なく招待できます。ピッカーが空になるケースの回避策です。
### 招待リンクの作り方
オーナーまたは管理者だけが作れます。**設定 → メンバー** の「招待リンク」で、付与する役割(**編集者** または **閲覧者**)と有効期限(**無期限 / 7日 / 30日**)を選んで作成します。オーナー権限はリンクでは付与できません。
作成すると URL が表示されるので、コピーしてメールやチャットなど任意の経路で相手に渡します。リンクはワークスペースごとに1本だけ有効です。
### 参加する側の流れ
1. 受け取ったリンク(`/ui/invite/<トークン>`)を開く
2. 未ログインならログイン(ログイン後、自動で招待画面に戻ります)
3. 参加先のワークスペース名と付与される役割を確認して「参加」
参加できるのは承認済みのログインユーザーだけです。招待リンクで新規アカウントは作れません(サインアップは従来どおり承認制)。
### 再生成・無効化
- **再生成**: 役割や有効期限を変えたいときは作り直します。古いリンクは即座に無効になります。
- **無効化**: 共有をやめたいときはリンクを無効化します。以後そのリンクでは参加できません。
無効・期限切れ・存在しないリンクを開いても、ワークスペースの情報は一切表示されません。認証なし(単独運用)モードでは招待リンクは使えません。
## AGENTS.md
@@ -41,48 +67,28 @@ TopBar → **ユーザーフォルダ** タブで開きます。左にサブフ
- 出典が必要なときは URL を明記
```
設定方法:
設定方法: ワークスペース → **設定 → AGENTS.md** で記述して保存します。最大 64 KB。注入トークンを節約するため、詳細はメモリに分散し AGENTS.md は短く保つのがコツです(→「[メモリと学習](12-memory.md)」)。
1. ユーザーフォルダ → **AGENTS.md**
2. テキストエリアに記述
3. 保存
## ブラウザ(セッション / マクロ / 録画)
最大 64 KB。注入トークンを節約するため、詳細は memory/ に分散し AGENTS.md は短く保つのがコツです(→「[メモリと学習](12-memory.md)」)
ブラウザセッション・マクロ・録画は、各ワークスペースの **設定 → ブラウザ** で管理します。そのワークスペースで作業中に保存したものは、ワークスペースのフォルダにまとまります
## notes/
- **セッション**: CAPTCHA / 2FA を越えて取得した cookie / storage を暗号化保存します。noVNC 画面でログイン → save の流れで作成し、ブラウザマクロから `session_profile_id` で参照します。セッションは作成者の鍵で暗号化されるため、利用できるのは作成した本人だけです。別のメンバーには一覧に「作成者のみ利用可」と表示されます。
- **マクロ(Playwright)**: ログイン済みブラウザを使った Web 操作を自動化します。`RunUserScript` で実行され、`main({ context, params })``context` は Playwright の BrowserContext です。
- **録画**: BrowseWeb 呼び出しで `recordTo` を指定すると、成功アクションがタスク終了時に JSON として書き出されます。録画から「Save as Script」でマクロ化できます。
他のエージェントや他ユーザーと共有したい情報を Markdown で書く場所です。visibility(公開範囲)を設定でき、エージェントは `SearchNotes` / `ReadNote` / `WriteNote` でアクセスします。notes はフォルダ階層を持てます(`notes/<folder>/<file>.md`
> 以前あった scripts/(汎用 Node)と templates/(雛形)は廃止されています。雛形・手順書は **スキル** に、アドホックなコード実行はエージェントの **Bash** ツールに統一されています
## browser-macros/Playwright
## MCP / スキル / SSH
ログイン済みブラウザを使った Web 操作を自動化します。`RunUserScript` で実行され、`main({ context, params })``context` は Playwright の BrowserContext です。`session_profile_id` を指定すると保存済みログイン(browser-sessions/)を復元してから実行します。
- **MCP**: MCP サーバーの登録・接続・ツール一覧取得。credentials は暗号化保存(→「[MCP 連携](13-mcp.md)」)
- **スキル**: スキル(参照知識・手順書)の作成・URL インストール・編集(→「[Skills](11-skills.md)」)
- **SSH**: SSH 接続の登録。秘密鍵は envelope encryption、ホストキーは TOFU で確認後に固定(→「[SSH 連携](14-ssh.md)」)
recordings/ の操作トレースから「Save as Script」でマクロ化することもできます。
## メモリ
> 以前あった scripts/(汎用 Node)と templates/(雛形)は廃止されました。雛形・手順書は **Skills** に、アドホックなコード実行はエージェントの **Bash** ツールに統一されています
エージェントの永続事実置き場です。エントリの閲覧・編集・削除はワークスペース → **設定 → メモリ** で行います。自動学習(reflection)の実行履歴・revert **設定 → Reflection 履歴** にあります(→「[メモリと学習](12-memory.md)」)
## browser-sessions/
## Pets
CAPTCHA / 2FA を越えて取得した cookie / storage を user-scoped に暗号化保存します。browser-macros から `session_profile_id` で参照します。ログインは noVNC 画面でログイン → save の流れで行います。
## mcp/ / skills/ / ssh-connections/
- **mcp/**: MCP サーバーの登録・接続・ツール一覧取得。credentials は暗号化保存(→「[MCP 連携](13-mcp.md)」)
- **skills/**: スキル(参照知識・手順書)の作成・URL インストール・編集。Settings → Skills と同じ画面(→「[Skills](11-skills.md)」)
- **ssh-connections/**: SSH 接続の登録。秘密鍵は envelope encryption、ホストキーは TOFU で確認後に固定(→「[SSH 連携](14-ssh.md)」)
## recordings/ / Subscribed Notes / trash/
- **recordings/**: BrowseWeb 呼び出しで `recordTo` を指定すると、成功アクションがタスク終了時に JSON として書き出されます。「Save as Script」でマクロ化できます
- **Subscribed Notes**: 他ユーザーが公開している notes を購読・発見します。inject モードは LLM コンテキストへ自動注入、search モードは `SearchNotes` で横断検索できます
- **trash/**: 削除ファイルの退避先。ハードデリートはせず一定期間後に自動 cleanup されます。閲覧は read-only
## memory/
エージェントの永続事実置き場です。エントリの閲覧・編集・削除はこのパネルで行います。自動学習(reflection)の実行履歴・revert は Settings → **Reflection 履歴** にあります(→「[メモリと学習](12-memory.md)」)。
## ファイルの作成・編集
- 作成できるのは **browser-macros**(左ツリーで選択 → 新規ファイルフォーム)
- 既存ファイルはツリーから選んでエディタで編集 → 保存
- AGENTS.md・browser-sessions・mcp・skills・pets・ssh-connections・Subscribed Notes は専用パネルで操作します
チャット画面に表示するキャラクター(マスコット)です。**設定 → Pets** で ZIP のインポート・既定キャラクターの選択・worker / backend ごとの割り当てを行います。Pets はユーザー単位の設定なので、ワークスペースではなくグローバルの設定にあります。
+18 -3
View File
@@ -3,7 +3,7 @@ id: skills
title: Skills(スキル)
category: advanced
order: 110
keywords: [Skills, スキル, インストール, Git URL, ReadSkill, per-task]
keywords: [Skills, スキル, インストール, Git URL, ReadSkill, per-task, ワークスペース, 共有, スペース]
---
# Skills(スキル)
@@ -26,9 +26,9 @@ keywords: [Skills, スキル, インストール, Git URL, ReadSkill, per-task]
利用可能なスキルは、movement 開始時に system prompt の **Skills Index** として一覧注入されます。エージェントは概要を見て「これは使える」と判断したら `ReadSkill({ name })` で全文を読み込みます。`ListSkills` で一覧、`InstallSkill` でタスク中に新規インストールもできます。
## スキルの追加(Settings → Skills
## スキルの追加(ワークスペース → 設定 → スキル
Settings → **Skills**(またはユーザーフォルダ → skills)で管理します。2 カラムの list + detail 構成です。
ワークスペースを開いて **設定 → スキル** で管理します(旧「ユーザーフォルダ → skills」タブは廃止され、ワークスペース設定に集約されました)。2 カラムの list + detail 構成です。
### 手動作成
@@ -46,6 +46,21 @@ Settings → **Skills**(またはユーザーフォルダ → skills)で管
一覧から選ぶと右に詳細(説明・トリガー・本文・セキュリティ検査結果)が出ます。**Edit** / **Delete** で更新できます。system スコープのスキルは admin のみ編集可能です。
## 共有ワークスペースでの可視性
個人スコープ(Personal / user)で作ったスキルの置き場所は、**いま作業しているワークスペースで決まります**。AGENTS.md・メモリ・ピースと同じ扱いです。
| 作業中のワークスペース | 見える人 |
|---|---|
| 共有ワークスペース(案件など) | そのワークスペースのメンバー全員 |
| 個人ワークスペース | 自分だけ |
共有ワークスペースで作ったスキルはメンバー間で共有され、各メンバーのエージェントの Skills Index にも出ます。個人ワークスペースで作ったスキルは自分専用で、他の人には見えません。
共有ワークスペースのスキルは他メンバーのジョブからも実行され得ます(共有ピース・共有 AGENTS.md と同じ信頼境界)。秘匿情報や自分専用の手順は、個人ワークスペースで作ってください。
System スコープは作業中のワークスペースに関係なく常に全ユーザー共有(admin のみ)です。
## セキュリティ検査
インストール・作成時にスキル内容がセキュリティスキャンされます。
+3 -3
View File
@@ -37,9 +37,9 @@ keywords: [メモリ, memory, MEMORY.md, 学習, Memory & Learning]
手動で書いたメモリは Reflection が動いていなくても確実に注入されるため、「いつも忘れられる」と感じる指示はメモリに 1 件書くのが確実です。
## メモリの閲覧・編集(ユーザーフォルダ → memory/
## メモリの閲覧・編集(ワークスペース → 設定 → メモリ
メモリエントリの管理は **ユーザーフォルダ → memory/** で行います。
メモリエントリの管理は、ワークスペースを開いて **設定 → メモリ** で行います(旧「ユーザーフォルダ → memory/」タブは廃止され、ワークスペース設定に集約されました。→「[個人の資産(ワークスペース設定)](09-userfolder.md)」)。個人ワークスペースなら自分専用、案件ワークスペースならメンバー共有のメモリを編集します。
### メモリエントリ
@@ -67,7 +67,7 @@ keywords: [メモリ, memory, MEMORY.md, 学習, Memory & Learning]
## メモリと AGENTS.md の使い分け
User Folder の AGENTS.md→「[User Folder](09-userfolder.md)」)は全文が毎回注入される固定の恒久指示、メモリはインデックスのみ注入し本文は必要時に読む構造です。
AGENTS.md(ワークスペース → 設定 → AGENTS.md→「[個人の資産(ワークスペース設定)](09-userfolder.md)」)は全文が毎回注入される固定の恒久指示、メモリはインデックスのみ注入し本文は必要時に読む構造です。
- **AGENTS.md**: 短く要約された必須ルール・トーン・出力フォーマットの好み
- **メモリ**: 個別の人物・プロジェクト・経験的に得た知見(数を増やせる)
+4 -2
View File
@@ -16,8 +16,10 @@ MCP (Model Context Protocol) は、外部サービスのツールをエージェ
登録方法は 2 経路あります。
- 個人用: TopBar → ユーザーフォルダ → MCP サーバー タブ → 「+ 追加」。`owner_id` が自分にセットされ、他のユーザーからは見えません
- 全体共有 (admin): admin が同じ画面の「全体のサーバー」セクションから登録すると、組織全員が使えます。
- ワークスペース単位: ワークスペースを開いて **設定 → MCP** → 「+ 追加」。個人ワークスペースで登録したサーバーは自分専用、案件ワークスペースで登録したサーバーはそのワークスペースのメンバーで共有されます(旧「ユーザーフォルダ → MCP サーバータブは廃止され、ワークスペース設定に集約されました。→「[個人の資産(ワークスペース設定)](09-userfolder.md)」)
- 全体共有 (admin): admin が **設定 → MCP & Connections** の「全体のサーバー」セクションから登録すると、組織全員が使えます。
> 案件ワークスペースで登録した API key 方式のサーバーは、ワークスペースのオーナー・管理者が **設定 → MCP** からメンバーの利用可否を管理できます。資格情報(token / API key)はワークスペースの鍵で暗号化され、メンバーはサーバーを使えても生の値は見えません。
登録時に入力する主な項目:
+1 -1
View File
@@ -53,7 +53,7 @@ MAESTRO は、エージェントが SSH 経由でリモートホストを操作
## SSH 接続プロファイルを登録する
接続は TopBar → ユーザーフォルダ → SSH 接続 から登録します
接続は、ワークスペースを開いて **設定 → SSH** から登録します(旧「ユーザーフォルダ → SSH 接続」タブは廃止され、ワークスペース設定に集約されました)。個人ワークスペースで登録すれば自分専用、案件ワークスペースで登録すればメンバー共有の接続になります。秘密鍵はワークスペースの鍵で暗号化保存され、メンバーは接続を使えても鍵の中身は見えません
1. 「+ 新規作成」で接続を作成 (label / host / user など)
2. 鍵の公開鍵をリモートの `authorized_keys` に登録
+11 -1
View File
@@ -3,7 +3,7 @@ id: llm-gateway
title: LLM Gateway 連携
category: advanced
order: 150
keywords: [LLM Gateway, LiteLLM, プロキシ, モデル, Virtual Keys]
keywords: [LLM Gateway, LiteLLM, プロキシ, モデル, Virtual Keys, 使用量, Usage, トークン]
---
## LLM Gateway とは
@@ -69,3 +69,13 @@ LiteLLM の構築手順・モデル定義・料金体系・Enterprise 機能と
Gateway / Worker いずれも Prometheus 互換の `/metrics` を公開できます (デフォルト有効)。team / backend / key prefix などのラベルで per-team の利用量・レイテンシ・バックエンド稼働を集計できます。`/metrics` は機密情報を含むため、内部ネットワークに限定して公開してください。
詳細な metric 一覧・Grafana クエリ例・scrape 設定は `docs/aao-gateway-overview.md` を参照してください。
## 使用量(Usage)タブ
上部の **使用量** タブでは、LLM のトークン使用量を日次で確認できます。Gateway 経由と Direct(各 worker が直接接続)の両方を合算し、お使いの環境のローカル日付で集計します。
- **内訳の切り替え**: 経路・モデル・バックエンド・ユーザー・組織で分解して見られます
- **集計の対象**: 入力 / 出力トークンとリクエスト数。ユーザー別の一覧も出ます
- Virtual Keys のキー別課金パネル(admin の Gateway 設定内)とは **別集計** です。Usage タブは「実際に消費したトークン量の可視化」、課金パネルは「キーごとの予算管理」と捉えてください
このタブは管理者専用ではなく、ログインユーザーが自分の使用量を確認できます。
-2
View File
@@ -25,12 +25,10 @@ movement の開始時には、その movement で使えるツールの一覧と
| ブラウザ | 実ブラウザでのページ操作 | BrowseWeb |
| Office / ドキュメント | Excel / Word / PDF / PPTX の解析 | ReadExcel / ReadPdf / ReadDocx |
| データ | SQLite データベース操作 | SQLite |
| 知識検索 | ドキュメントの取り込みと検索 | SearchKnowledge / ListDocuments |
| 画像 | 画像の読み取り・注釈 | ReadImage / AnnotateImage |
| レビュー | LLM による一括レビュー | BatchReviewTextWithLLM |
| スライド | PPTX スライド生成 | AddSlide / BuildPptx / SetTheme |
| チェックリスト | タスク内の進捗チェックリスト | CreateChecklist / CheckItem |
| ノート | 共有ノートの検索・読み書き | SearchNotes / ReadNote / WriteNote |
| SSH | リモート実行・転送・対話コンソール | SshExec / SshUpload / SshConsoleSend |
| オーケストレーション | サブタスクの生成 | SpawnSubTask |
| 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections |
+23 -3
View File
@@ -22,13 +22,16 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は *
サイドバーのグループとセクションは以下の通りです。
### User グループ (全ユーザー)
### Preference グループ (全ユーザー)
ペットや通知など、ワークスペース単位ではなく**個人単位**の設定をまとめたグループです。
| セクション | 内容 |
|-----------|------|
| Preferences | 自分の新規タスクのデフォルト公開範囲などの個人設定 |
| 🔔 Notifications | ブラウザ通知 / Web Push の購読設定 |
| 🧠 Reflection 履歴 | Reflection の実行履歴・差分の閲覧と revert(memory エントリの編集は ユーザーフォルダ → memory/) |
| ◉ Pets | チャットのマスコット(ペット)と担当ワーカー/バックエンドの設定。個人単位なので Preference グループにあります |
| 🧠 Reflection 履歴 | Reflection の実行履歴・差分の閲覧と revert(memory エントリの編集は ワークスペース → 設定 → メモリ) |
通知の詳細は [ブラウザ通知](#notifications)、memory の詳細は [メモリと学習](#memory) を参照。
@@ -39,6 +42,7 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は *
| Branding | アプリ名・ロゴ・アクセント色などの見た目 |
| Paths & Storage | `storage.*` の作業ディレクトリ・ユーザーフォルダ・アップロード上限 |
| Execution | `concurrency` (全 worker 合計の並列度)・`max_movements``retry.*` |
| HTTPS / TLS | `server.tls.*` — アプリ内 TLS 終端・証明書・HTTP→HTTPS リダイレクト・HSTS。詳細は下の「[HTTPS とリダイレクト](#https-とリダイレクト)」 |
### LLM グループ (admin)
@@ -67,7 +71,6 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。
| Browser Runtime | Playwright BrowseWeb のタイムアウト・channel など |
| Media & Documents | Vision / OCR / 音声 / Office ファイルの上限 |
| External Services | X / Maps / Amazon などの外部 API キー |
| Legacy Knowledge | 旧 DKS 設定 (新規 namespace は MCP に移行) |
### MCP & Connections グループ (admin)
@@ -106,6 +109,23 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。
- `auth.providers.*` (認証プロバイダ)
- `db_path`
- `port`
- `server.tls.*` (HTTPS / 証明書 / リダイレクト / HSTS)
## HTTPS とリダイレクト
**HTTPS / TLS** セクション (`server.tls.*`) でアプリ自身に TLS を終端させられます。リバースプロキシで TLS を終端している場合はオフのままにしてください(二重終端になります)。HTTPS 関連の変更は **再起動が必要** です(フォーム上部に常時バナーが出ます)。
主な項目:
- **HTTPS で配信** — アプリ内で TLS を終端。既定は自己署名証明書のため、正式な証明書を入れるまでブラウザに警告が出ます
- **HTTP を HTTPS へリダイレクト** — 平文 HTTP のアクセスを HTTPS へ転送。**一時リダイレクト(302)** で送るため、オフにすればリダイレクトは止まります(ブラウザに恒久キャッシュされません)
- **HSTS を送出** — 既定オフ。正式な証明書がある場合のみオンにしてください
> ⚠️ **「オフにしてもずっと HTTPS にリダイレクトされる」場合**
>
> 原因はサーバ設定ではなく、ブラウザに焼き付いた **HSTSHTTP Strict Transport Security** です。HSTS はブラウザを HTTPS に固定し、一度送るとサーバ側でオフにしても解除できません(ブラウザがリクエスト前に自分で HTTPS へ昇格するため)。
>
> 本バージョン以降、HSTS は既定オフで、**オフの間はサーバが固定を解除する信号(`max-age=0`)を送ります**。HTTPS をオンのまま一度アプリを HTTPS で開けば、固定が解除されて平文 HTTP に戻れます。証明書を入れて意図的に固定したい場合のみ HSTS をオンにしてください。
## センシティブ値の扱い
+1 -1
View File
@@ -61,7 +61,7 @@ Reflection が piece への変更を提案しても、**組み込み piece (`pie
学習結果は次の 2 か所で見えます。
- **設定 → 🧠 Reflection 履歴** — Reflection の適用履歴と各履歴の **revert ボタン**memory の現在値・編集は ユーザーフォルダ → memory/)。詳細は [メモリと学習](#memory) を参照
- **設定 → 🧠 Reflection 履歴** — Reflection の適用履歴と各履歴の **revert ボタン**memory の現在値・編集は ワークスペース → 設定 → メモリ)。詳細は [メモリと学習](#memory) を参照
- **タスク詳細の概要タブ** — そのタスクの Reflection が実際に変更を加えた場合だけ **🧠 Learned N things** バッジが出る (piece も編集した場合は「+ piece edit」付き)
revert は before snapshot から memory / piece を書き戻します。ユーザー自身の編集を上書きしないよう CAS (Compare-and-Swap) ベースで安全に実装されています。
+29
View File
@@ -0,0 +1,29 @@
---
id: workspace-apps
title: ワークスペース・アプリ
category: advanced
order: 200
keywords: [アプリ, app, GUI, apps, サンドボックス, ワークスペース, ツール]
---
## ワークスペース・アプリとは
ワークスペース・アプリは、そのワークスペースの中で動く小さな HTML 製の道具です。たとえば「`output/` のファイルを一覧して選んだものにメモを付ける」「集めたデータを表やグラフで見る」といった、チャットの往復だけでは面倒な作業を、ボタンやフォームのある画面で片付けられます。
アプリはワークスペースの **「アプリ」タブ** に並びます。「開く」を押すとその場で起動し、ワークスペースのファイルを読み書きできます。
## エージェントに作ってもらう
アプリは自分で書く必要はありません。チャットで **「ワークスペース・アプリを作って」** と頼めば、エージェントが用途に合わせた HTML を組み立て、ワークスペースの `apps/` フォルダに保存します。保存されると「アプリ」タブに表示され、すぐ開けます。
例: 「このワークスペースの請求データから請求書を作るアプリを作って」「`output/` のレポートを並べて読めるビューアがほしい」。
## 安全な動き方
アプリは隔離された領域(サンドボックス)で動きます。**外部ネットワークには一切つながりません** — 外部サイトへデータを送ったり、外部から何かを読み込んだりはできない設計です。ファイルの読み書きは、すべてあなたのログイン権限の範囲内で、ワークスペースの中だけに限られます。`output/` 以外への書き込みや削除は、実行のたびに確認が出ます。
## どこに置かれるか
アプリはワークスペースの `apps/{名前}/index.html` に置かれます。ファイルタブの `apps/` を開けば実体を確認でき、「アプリとして実行」からも起動できます。各アプリには表示名・説明を持たせる `app.json` を任意で添えられます(無ければフォルダ名が表示名になります)。
アプリの作り方の技術仕様(エージェント向け)は、リポジトリの `docs/workspace-apps-bridge.md` にまとまっています。
+61
View File
@@ -0,0 +1,61 @@
---
id: workspaces
title: ワークスペースとメンバー
category: basic
order: 15
keywords: [ワークスペース, 個人ワークスペース, 案件ワークスペース, スペース, メンバー, 招待リンク, 共有, 可視性, 資格情報, principal]
---
# ワークスペースとメンバー
**ワークスペース** は、タスク・ファイル・メモリ・piece・スキル・MCP/SSH 接続・ブラウザセッションといった「作業に必要なもの一式」をまとめる入れ物です。MAESTRO のデータと共有範囲は、すべてワークスペース単位で決まります。
## 2 種類のワークスペース
| 種類 | 用途 | 共有 |
|---|---|---|
| 個人ワークスペース | あなた専用の作業場。既定の入口で、毎アカウントに 1 つ用意されます | 自分だけ(管理者は閲覧可) |
| 案件ワークスペース | チームや案件ごとに作る共有の作業場 | 招待したメンバー全員 |
「タスク」タブは常に **個人ワークスペース** を開いています。案件ワークスペースは **ワークスペース** タブから開きます。
## ワークスペースを切り替える
上部の **ワークスペース** タブを開くと、参加しているワークスペースの一覧が出ます。選ぶと、そのワークスペースのタスク・ファイル・設定に切り替わります。新規作成は一覧上部のボタンから行います(作った人がオーナーになります)。
一覧では、いま実行中のタスクがあるワークスペースに緑の **「● N 実行中」** バッジが付きます(実行中が 0 件のときは表示されません)。どのワークスペースで作業が動いているかを開かずに把握できます。件数は自動で更新されます。
## チャットを探す
ワークスペースの「チャット」一覧には、タスク一覧ページと同じ検索・絞り込みバーがあります。キーワード(タイトル・本文・Piece・作成者名)での検索、状態(実行中・待機・失敗など)のタブ絞り込み、並び替え(更新順・状態・タイトル)が使えます。共有ワークスペースで「自分 / 他のメンバー」を切り替えている場合は、その範囲の中で絞り込みます。
## メンバーと招待
案件ワークスペースは複数人で共有します。メンバーの追加方法と招待リンクの作り方・参加の流れは [個人の資産(ワークスペース設定)](./09-userfolder.md) にまとめています。要点だけ挙げると:
- **ピッカー追加**: 同じ組織のユーザーを一覧から選ぶ
- **招待リンク**: 組織に関係なくリンクを知っている相手を招待する(役割と有効期限を指定)
- 役割は **オーナー / 編集者 / 閲覧者**。閲覧者はファイルのダウンロードや閲覧はできるが、編集・削除はできません
## 共有範囲の考え方
ワークスペースの中で始めたタスク(チャット)は、**常にそのワークスペースのメンバー全員に公開**され、メンバー以外には見えません。タスクごとに公開範囲を選ぶ設定はありません。「誰に見せるか」はメンバー構成で決まります。
個人ワークスペースのタスクだけは、作成時に **非公開 / 組織 / 公開** の公開範囲を選べます(→[タスクを作って実行する](./02-tasks.md))。
## 資格情報はワークスペースが持つ
メモリ・piece・スキル・MCP サーバー・SSH 接続・ブラウザセッション・自動学習(Reflection)は、**作業中のワークスペースに属します**。
- 個人ワークスペースで登録・作成したものは自分専用
- 案件ワークスペースで登録・作成したものはメンバー共有
token・API key・SSH 秘密鍵などの機密値は、そのワークスペースの鍵で暗号化して保存されます。メンバーは登録済みの接続を **使えます** が、生の資格情報そのものは見えません。秘匿したい情報や自分だけの手順は、個人ワークスペースで登録してください。
> Google ログインのアカウント連携(OAuth)だけは例外で、引き続きユーザー個人に紐づきます。共有されるのは「サーバーへの接続設定」であって、あなた個人の Google アカウントではありません。
## 関連
- 各設定(AGENTS.md / メモリ / Pieces / スキル / MCP / SSH / ブラウザ / メンバー / 招待リンク)の詳しい場所と操作 → [個人の資産(ワークスペース設定)](./09-userfolder.md)
- ワークスペースの活動を日付で振り返る → [カレンダー](./22-calendar.md)
- メンバーの役割・権限・安全管理(admin) → [ユーザー管理 / 安全性](./19-admin.md)
+44
View File
@@ -0,0 +1,44 @@
---
id: calendar
title: カレンダー
category: basic
order: 25
keywords: [カレンダー, 日次, サマリ, 予定, 変更ファイル, アクティビティ, 振り返り]
---
# カレンダー
**カレンダー** タブは、ワークスペースの活動を日付ごとに振り返るための画面です。「いつ何をしたか」「どのファイルが変わったか」「この先の予定」を 1 か所で見渡せます。
## 日ごとに分かること
カレンダーの日付を選ぶと、その日について次が表示されます。
| 区分 | 内容 |
|---|---|
| タスク | その日に動いたタスク(実行・完了) |
| 予定 | 登録した予定や、その日に実行される予定のスケジュール |
| 変更ファイル | その日に作成・更新されたワークスペースのファイル |
各日のマスにはタスク件数のバッジと、予定が横棒で表示されるので、活動のあった日がひと目で分かります。日々の作業ログ(アクティビティ・ジャーナル)としても使えます。
## 表示を絞り込む
カレンダー上部の「タスク / 変更ファイル / 予定」のボタンで、見たい種類だけに絞り込めます。たとえば予定だけを追いたいときはタスクと変更ファイルをオフにできます。絞り込みの状態はこの端末に記憶され、次に開いたときも保たれます。
## 複数日にまたがる予定
予定には開始日と終了日を設定できます。終了日を入れると、月の表示では開始日から終了日まで横棒でつながって表示され、出張・合宿・キャンペーン期間のような「何日か続く予定」が分かりやすくなります。終了日を空にすれば単日の予定に戻ります。
スマートフォンでは、上に月のカレンダー、下にその日の詳細、という上下 2 分割で表示されます。
## ワークスペース横断ビュー
複数のワークスペースに参加している場合は、横断ビューで「その日にどのワークスペースで活動があったか」をまとめて確認できます。活動のないワークスペースは表示されません。気になるワークスペースを選べば、そのまま個別のカレンダーに移れます。
## スケジュールとの違い
- **スケジュール**(→[スケジュールで自動実行](./06-schedules.md))は「これから自動で動かす設定」を登録・管理する場所
- **カレンダー** は、その結果も含めた「実際の活動と予定」を日付軸で振り返る場所
定期実行を仕込むのはスケジュール、流れを俯瞰するのはカレンダー、と使い分けてください。
-56
View File
@@ -1,56 +0,0 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchDashboardWidgets,
createDashboardWidget,
updateDashboardWidget,
deleteDashboardWidget,
reorderDashboardWidgets,
type DashboardWidget,
type DashboardWidgetKind,
} from '../api';
const QK = ['dashboard', 'widgets'] as const;
export function useDashboardWidgets() {
const qc = useQueryClient();
const list = useQuery({
queryKey: QK,
queryFn: fetchDashboardWidgets,
staleTime: 10_000,
});
const create = useMutation({
mutationFn: (input: { slug: string; title: string; content?: string; kind?: DashboardWidgetKind }) =>
createDashboardWidget(input),
onSuccess: () => qc.invalidateQueries({ queryKey: QK }),
});
const update = useMutation({
mutationFn: ({ id, patch }: { id: number; patch: { title?: string; content?: string } }) =>
updateDashboardWidget(id, patch),
onSuccess: () => qc.invalidateQueries({ queryKey: QK }),
});
const remove = useMutation({
mutationFn: (id: number) => deleteDashboardWidget(id),
onSuccess: () => qc.invalidateQueries({ queryKey: QK }),
});
const reorder = useMutation({
mutationFn: (ids: number[]) => reorderDashboardWidgets(ids),
onSuccess: () => qc.invalidateQueries({ queryKey: QK }),
});
return {
widgets: list.data ?? [],
isLoading: list.isLoading,
isError: list.isError,
create,
update,
remove,
reorder,
};
}
export type { DashboardWidget };
+106 -7
View File
@@ -1,18 +1,45 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchLocalFiles, type LocalFileEntry } from '../api';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
fetchLocalFiles,
uploadLocalFiles,
deleteLocalFiles,
downloadLocalFilesZip,
type LocalFileEntry,
type WritableTaskSection,
} from '../api';
import { filesToBase64 } from '../lib/fileBase64';
type FileSection = 'workspace' | 'input' | 'output' | 'logs';
/** アップロード・削除が許される区分(サーバの WRITABLE_SECTIONS と一致)。 */
function isWritableSection(section: FileSection): section is WritableTaskSection {
return section === 'input' || section === 'output';
}
export function useFileBrowser(taskId: number | null) {
const [section, setSection] = useState<'workspace' | 'input' | 'output' | 'logs'>('workspace');
// 初期値は reset effecttask 切替で 'output' に倒す)と一致させる。'workspace' 初期だと
// 初回 fetch が古い section で走り、reset 後の fetch とレースになる。
const [section, setSection] = useState<FileSection>('output');
const [currentPath, setCurrentPath] = useState('');
const [entries, setEntries] = useState<LocalFileEntry[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
// fetch のリクエスト連番。古い (taskId/section/path) のレスポンスが後勝ちで entries を
// 上書きするのを防ぐため、最新の seq の結果だけ反映する。
const reqSeq = useRef(0);
// 複数選択(チェック済みファイルの相対パス集合)と書込中フラグ・メッセージ。
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const [isUploading, setIsUploading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [message, setMessage] = useState<{ text: string; kind: 'ok' | 'error' } | null>(null);
// Fetch entries when taskId/section/path changes
useEffect(() => {
if (!taskId) return;
const seq = ++reqSeq.current;
fetchLocalFiles(taskId, section, currentPath)
.then(r => setEntries(r.entries))
.catch(() => setEntries([]));
.then(r => { if (seq === reqSeq.current) setEntries(r.entries); })
.catch(() => { if (seq === reqSeq.current) setEntries([]); });
}, [taskId, section, currentPath]);
// Reset when task changes
@@ -21,19 +48,85 @@ export function useFileBrowser(taskId: number | null) {
setCurrentPath('');
}, [taskId]);
// フォルダ移動・区分切替・タスク切替で選択をクリア(別ディレクトリのパスを持ち越さない)。
useEffect(() => { setSelected(new Set()); }, [taskId, section, currentPath]);
const refresh = useCallback(async () => {
if (!taskId) return;
const seq = ++reqSeq.current;
setIsRefreshing(true);
try {
const r = await fetchLocalFiles(taskId, section, currentPath);
setEntries(r.entries);
if (seq === reqSeq.current) setEntries(r.entries);
} catch {
setEntries([]);
if (seq === reqSeq.current) setEntries([]);
} finally {
setIsRefreshing(false);
}
}, [taskId, section, currentPath]);
const toggleSelect = useCallback((path: string) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(path)) next.delete(path); else next.add(path);
return next;
});
}, []);
const toggleSelectAll = useCallback((paths: string[]) => {
setSelected(prev => {
const allSelected = paths.length > 0 && paths.every(p => prev.has(p));
return allSelected ? new Set() : new Set(paths);
});
}, []);
const upload = useCallback(async (fileList: File[]) => {
if (!taskId || fileList.length === 0 || !isWritableSection(section)) return;
setIsUploading(true);
setMessage(null);
try {
const payload = await filesToBase64(fileList);
const r = await uploadLocalFiles(taskId, section, currentPath, payload);
await refresh();
setMessage({ text: `${r.uploaded.length} 件のファイルを追加しました`, kind: 'ok' });
} catch (e) {
setMessage({ text: `アップロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
} finally {
setIsUploading(false);
}
}, [taskId, section, currentPath, refresh]);
const remove = useCallback(async (paths: string[]) => {
if (!taskId || paths.length === 0 || !isWritableSection(section)) return;
if (!window.confirm(`${paths.length} 件のファイルを削除しますか?この操作は取り消せません。`)) return;
setIsDeleting(true);
setMessage(null);
try {
const r = await deleteLocalFiles(taskId, section, paths);
setSelected(new Set());
await refresh();
setMessage({ text: `${r.deleted.length} 件のファイルを削除しました`, kind: 'ok' });
} catch (e) {
setMessage({ text: `削除に失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
} finally {
setIsDeleting(false);
}
}, [taskId, section, currentPath, refresh]);
// 選択ファイルを zip でダウンロード(read 操作、全 section 可)。
const download = useCallback(async (paths: string[]) => {
if (!taskId || paths.length === 0) return;
setIsDownloading(true);
setMessage(null);
try {
await downloadLocalFilesZip(taskId, section, paths);
} catch (e) {
setMessage({ text: `ダウンロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
} finally {
setIsDownloading(false);
}
}, [taskId, section]);
const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : [];
return {
@@ -43,5 +136,11 @@ export function useFileBrowser(taskId: number | null) {
isRefreshing,
refresh,
pathSegments,
// 書込(アップロード/削除)
writableSection: isWritableSection(section),
selected, toggleSelect, toggleSelectAll,
upload, remove, download,
isUploading, isDeleting, isDownloading,
message,
};
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
/**
* True when the viewport is narrower than Tailwind's `md` breakpoint (768px).
* Used to mount mobile-only (touch/swipe) vs desktop (click) UI as a SINGLE DOM
* branch — rendering both branches and hiding one with CSS would duplicate
* heavy/stateful children (e.g. a ChatPane textarea), which breaks strict
* locators and mounts redundant side-effecting content.
*
* @param maxWidthPx breakpoint in px (default 768 = Tailwind `md`).
*/
export function useIsMobile(maxWidthPx = 768): boolean {
const query = `(max-width: ${maxWidthPx - 0.02}px)`;
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(query).matches
: false,
);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const mql = window.matchMedia(query);
const onChange = () => setIsMobile(mql.matches);
onChange();
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);
return isMobile;
}
+10 -5
View File
@@ -2,19 +2,24 @@ import { useQuery } from '@tanstack/react-query';
import { fetchPieces, fetchPiece } from '../api';
import { STALE_TIME } from '../lib/constants.js';
export function usePieceList() {
return useQuery({ queryKey: ['pieces'], queryFn: fetchPieces, staleTime: STALE_TIME.SEMI_STATIC });
export function usePieceList(spaceId?: string) {
return useQuery({
queryKey: ['pieces', spaceId ?? null],
queryFn: () => fetchPieces(spaceId),
staleTime: STALE_TIME.SEMI_STATIC,
});
}
/**
* Fetches a single piece by name (and optional source).
* Returns the full PieceFetchResult so callers can use the server-resolved source
* for authorization decisions (e.g. read-only gate in PieceEditor).
* When `spaceId` is set, resolves against the space folder's custom pieces.
*/
export function usePiece(name: string | undefined, source?: 'builtin' | 'user-custom' | 'global-custom') {
export function usePiece(name: string | undefined, source?: 'builtin' | 'user-custom' | 'global-custom', spaceId?: string) {
return useQuery({
queryKey: ['piece', name, source],
queryFn: () => fetchPiece(name!, source),
queryKey: ['piece', spaceId ?? null, name, source],
queryFn: () => fetchPiece(name!, source, spaceId),
enabled: !!name,
staleTime: STALE_TIME.SEMI_STATIC,
});
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, type RefObject } from 'react';
import { THEME_CHANGE_EVENT } from '../lib/theme';
import { deriveSpaceBrandVars, type BrandVars } from '../lib/spaceBranding';
const VAR_NAMES: Array<keyof BrandVars> = [
'--brand-primary',
'--brand-primary-deep',
'--brand-primary-soft',
'--brand-primary-ring',
'--brand-primary-fg',
];
/**
* スペースのブランド色を、詳細パネルのコンテナ要素に scoped で適用する。
* - `brandColor` があり、かつパースできる → 5 つの --brand-primary 系変数を ref 要素に inline-set。
* 子孫の `text-accent` / `bg-accent` 等(CSS 変数参照)がスペース色になる。
* - soft tint はテーマ依存なので、テーマ切替・OS の dark 設定変更で再導出する。
* - スペースを離れる / brandColor が消える / アンマウント時は変数を削除し、
* グローバル(useBranding が documentElement に置いた既定値)へ自然に戻す。
*
* `useBranding` は documentElement:root)に対してアプリ全体のブランド色を適用するが、
* こちらは特定コンテナへの上書きなので両者は競合しない(より内側のスコープが勝つ)。
*/
export function useSpaceBranding(
containerRef: RefObject<HTMLElement>,
brandColor: string | null | undefined,
): void {
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const clear = () => {
for (const name of VAR_NAMES) el.style.removeProperty(name);
};
if (!brandColor) {
clear();
return;
}
const apply = () => {
const isDark = document.documentElement.dataset.theme === 'dark';
const vars = deriveSpaceBrandVars(brandColor, isDark);
if (!vars) {
// パース不能な色は scoped 適用しない(既定 branding に委ねる)。
clear();
return;
}
for (const name of VAR_NAMES) el.style.setProperty(name, vars[name]);
};
apply();
window.addEventListener(THEME_CHANGE_EVENT, apply);
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', apply);
return () => {
window.removeEventListener(THEME_CHANGE_EVENT, apply);
mq.removeEventListener('change', apply);
clear();
};
}, [containerRef, brandColor]);
}

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