This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/{名前}/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>
|
||||
);
|
||||
}
|
||||
@@ -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/D–M/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; // 1–7
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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\}/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user