This commit is contained in:
@@ -18,6 +18,14 @@ interface ChatMessageProps {
|
||||
imageBaseUrl?: string;
|
||||
/** When true, this thinking comment has been superseded — show static dot instead of spinner */
|
||||
isStaleThinking?: boolean;
|
||||
/**
|
||||
* When true, hide comment attachment download chips. Attachments live under the
|
||||
* task's `input/` directory, which is NOT served by the public share endpoint
|
||||
* (`/api/shared/:token` serves only `output/`). The chips would otherwise point
|
||||
* at the authenticated `/api/local/...input` URL and break / leak scope on the
|
||||
* shared page. Shared view passes this as true.
|
||||
*/
|
||||
hideAttachments?: boolean;
|
||||
}
|
||||
|
||||
interface ProgressData {
|
||||
@@ -361,7 +369,7 @@ function CommentAttachments({ attachments, taskId }: { attachments?: string[]; t
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }: ChatMessageProps) {
|
||||
export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hideAttachments }: ChatMessageProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const { kind, author, body, createdAt } = comment;
|
||||
|
||||
@@ -380,7 +388,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<MarkdownText text={body} />
|
||||
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
|
||||
{!hideAttachments && <CommentAttachments attachments={comment.attachments} taskId={taskId} />}
|
||||
<div className={`text-[10px] mt-1.5 ${isPending ? 'text-amber-400' : 'text-green-500'}`}>
|
||||
{isPending ? t('message.waitingAgentAck') : t('message.acked', { time: new Date(comment.injectedAt!).toLocaleTimeString() })}
|
||||
</div>
|
||||
@@ -398,7 +406,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<MarkdownText text={body} />
|
||||
<CommentAttachments attachments={comment.attachments} taskId={taskId} />
|
||||
{!hideAttachments && <CommentAttachments attachments={comment.attachments} taskId={taskId} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,12 @@ import { LocalTask, LocalTaskComment } from '../../api';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
||||
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
|
||||
import { ContextUsageGauge } from '../detail/ContextUsageGauge';
|
||||
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
||||
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
||||
import RotatingTips from './RotatingTips';
|
||||
import { ToolRequestApproval } from './ToolRequestApproval';
|
||||
import { DelegateLiveConsole } from './DelegateLiveConsole';
|
||||
import { useJobStream } from '../../hooks/useJobStream';
|
||||
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||
|
||||
@@ -174,7 +177,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
};
|
||||
|
||||
const jobStatus = task.latestJob?.status;
|
||||
const { promptProgress, streamingText, toolCallStream, connected } = useJobStream(task.id, jobStatus);
|
||||
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams } = useJobStream(task.id, jobStatus);
|
||||
|
||||
// Most-recent content-field tool with decoded content to show live.
|
||||
const liveToolContent = useMemo(() => {
|
||||
@@ -199,6 +202,18 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
|
||||
const hasActiveJob = isBusy || isPending;
|
||||
|
||||
// Tool-request mechanism: while the agent is paused waiting for the user to
|
||||
// approve/deny a requested tool, lock the normal composer. Sending a regular
|
||||
// message here would create a SECOND job (a waiting_human reply) that runs in
|
||||
// parallel with the original once it's resumed by the approval → duplicate
|
||||
// execution. Gate on the JOB's waitReason (available immediately from the
|
||||
// task data) rather than the async tool-requests fetch, so there is no
|
||||
// unlocked gap right after the task flips to waiting_human. A disabled
|
||||
// textarea also blocks the Ctrl+Enter submit path.
|
||||
const awaitingToolApproval =
|
||||
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
|
||||
const composerLocked = inputLocked || awaitingToolApproval;
|
||||
|
||||
// Release the submit lock once the agent is visibly responding: the new user
|
||||
// comment is reflected in the list AND the job has been picked up by a worker
|
||||
// (isBusy=true). This bridges the queued->dispatching gap where a stale
|
||||
@@ -235,16 +250,15 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full overflow-hidden">
|
||||
{/* Tablet+ only. Mobile renders its own app-level instance so the
|
||||
pet is visible across all mobile tabs (Progress / Files / Trace /
|
||||
Browser / SSH), not just Chat. */}
|
||||
{/* Shown at every breakpoint. The overlay is absolutely positioned at the
|
||||
bottom-right of the chat pane with pointer-events:none, so it never
|
||||
blocks the mobile composer — keep it visible on narrow screens too. */}
|
||||
<ChatPetOverlay
|
||||
taskId={task.id}
|
||||
taskStatus={task.latestJob?.status ?? null}
|
||||
currentActivity={task.latestJob?.currentActivity ?? null}
|
||||
workerId={task.latestJob?.workerId ?? null}
|
||||
lastBackendId={task.latestJob?.lastBackendId ?? null}
|
||||
className="hidden sm:block"
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 border-b border-hairline bg-canvas px-4 py-2.5">
|
||||
@@ -354,7 +368,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span>Processing</span>
|
||||
<span>{t('pane.processing')}</span>
|
||||
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1">
|
||||
@@ -390,6 +404,12 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy && Object.keys(delegateStreams).length > 0 && (
|
||||
<div className="flex justify-start mt-1.5">
|
||||
<DelegateLiveConsole streams={delegateStreams} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General rotating tips to make the wait useful (running only). */}
|
||||
{isBusy && <RotatingTips />}
|
||||
</div>
|
||||
@@ -430,6 +450,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
|
||||
</div>
|
||||
)}
|
||||
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||||
{sendError && !isBusy && (
|
||||
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300">
|
||||
<span className="truncate">⚠ {sendError}</span>
|
||||
@@ -453,6 +474,16 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* コンテキスト残量を入力欄の直上に常時表示。概要タブまでスクロールせずに、
|
||||
入力しながら「あとどれくらい書けるか」を把握できる(issue #009)。 */}
|
||||
<div className="mb-2">
|
||||
<ContextUsageGauge
|
||||
compact
|
||||
promptTokens={task.latestJob?.contextPromptTokens}
|
||||
limitTokens={task.latestJob?.contextLimitTokens}
|
||||
jobStatus={task.latestJob?.status}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5 items-end">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -463,7 +494,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={inputLocked || submitting}
|
||||
disabled={composerLocked || submitting}
|
||||
className="flex-shrink-0 w-9 h-9 flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-surface rounded-md transition-colors disabled:opacity-50 disabled:hover:bg-transparent disabled:cursor-not-allowed"
|
||||
title={t('pane.attachFile')}
|
||||
aria-label={t('pane.attachFile')}
|
||||
@@ -478,8 +509,8 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={e => void handlePaste(e)}
|
||||
rows={2}
|
||||
disabled={inputLocked}
|
||||
placeholder={inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
|
||||
disabled={composerLocked}
|
||||
placeholder={awaitingToolApproval ? t('toolRequest.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
|
||||
className="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
|
||||
/>
|
||||
{isBusy && onCancel ? (
|
||||
@@ -524,7 +555,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
disabled={submitting || inputLocked || (!draft.trim() && attachments.length === 0)}
|
||||
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import '../../i18n';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { DelegateLiveConsole } from './DelegateLiveConsole';
|
||||
import type { DelegateStreamEntry } from '../../hooks/useJobStream';
|
||||
|
||||
function entry(o: Partial<DelegateStreamEntry> = {}): DelegateStreamEntry {
|
||||
return {
|
||||
delegateRunId: 'r1', parentRunId: null, depth: 1, description: 'tweet 3 を深掘り',
|
||||
status: 'running', text: '論点を整理しています', currentTool: null, ...o,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DelegateLiveConsole', () => {
|
||||
it('streams が空なら何も描画しない', () => {
|
||||
const { container } = renderWithProviders(<DelegateLiveConsole streams={{}} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
it('実行中の delegate カードを description + ライブ文字つきで描画', () => {
|
||||
renderWithProviders(<DelegateLiveConsole streams={{ r1: entry() }} />);
|
||||
expect(screen.getByText('tweet 3 を深掘り')).toBeTruthy();
|
||||
expect(screen.getByText(/論点を整理しています/)).toBeTruthy();
|
||||
});
|
||||
it('currentTool があるとツール実行中を表示', () => {
|
||||
renderWithProviders(<DelegateLiveConsole streams={{ r1: entry({ text: '', currentTool: 'WebFetch' }) }} />);
|
||||
expect(screen.getByText(/WebFetch/)).toBeTruthy();
|
||||
});
|
||||
it('完了した run は表示しない(履歴は概要に委ねる)— running だけ残す', () => {
|
||||
const streams = {
|
||||
done1: entry({ delegateRunId: 'done1', description: '完了したやつ', status: 'success' }),
|
||||
run1: entry({ delegateRunId: 'run1', description: '走ってるやつ', status: 'running' }),
|
||||
};
|
||||
renderWithProviders(<DelegateLiveConsole streams={streams} />);
|
||||
expect(screen.queryByText('完了したやつ')).toBeNull();
|
||||
expect(screen.getByText('走ってるやつ')).toBeTruthy();
|
||||
});
|
||||
it('全 run が完了済みなら何も描画しない', () => {
|
||||
const streams = {
|
||||
done1: entry({ delegateRunId: 'done1', status: 'success' }),
|
||||
done2: entry({ delegateRunId: 'done2', status: 'aborted' }),
|
||||
};
|
||||
const { container } = renderWithProviders(<DelegateLiveConsole streams={streams} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
it('入れ子(parentRunId あり)を子としてインデント描画', () => {
|
||||
const streams = {
|
||||
r1: entry({ delegateRunId: 'r1', description: '親', depth: 1 }),
|
||||
r2: entry({ delegateRunId: 'r2', parentRunId: 'r1', description: '子', depth: 2 }),
|
||||
};
|
||||
renderWithProviders(<DelegateLiveConsole streams={streams} />);
|
||||
expect(screen.getByText('親')).toBeTruthy();
|
||||
expect(screen.getByText('子')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { delegateStatusBadge } from '../../lib/delegateRuns';
|
||||
import type { DelegateStreamEntry } from '../../hooks/useJobStream';
|
||||
|
||||
interface ConsoleNode extends DelegateStreamEntry {
|
||||
children: ConsoleNode[];
|
||||
}
|
||||
|
||||
/** delegateStreams(フラット)を親子ツリーに。孤児は root 扱い(取りこぼし無し)。 */
|
||||
function buildTree(streams: Record<string, DelegateStreamEntry>): ConsoleNode[] {
|
||||
const nodes = new Map<string, ConsoleNode>();
|
||||
for (const e of Object.values(streams)) nodes.set(e.delegateRunId, { ...e, children: [] });
|
||||
const roots: ConsoleNode[] = [];
|
||||
for (const node of nodes.values()) {
|
||||
const parent = node.parentRunId ? nodes.get(node.parentRunId) : undefined;
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function ConsoleCard({ node }: { node: ConsoleNode }) {
|
||||
const { t } = useTranslation('detail');
|
||||
const badge = delegateStatusBadge(node.status);
|
||||
const running = node.status === 'running';
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg bg-canvas/60 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface/50 border-b border-hairline">
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-2xs font-medium ${badge.cls}`}>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
<span className="text-[13px] text-slate-700 font-medium leading-tight truncate flex-1">
|
||||
{node.description}
|
||||
</span>
|
||||
</div>
|
||||
{(node.text || node.currentTool) && (
|
||||
<div className="px-3 py-2 text-[12px] text-slate-700 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{node.currentTool ? (
|
||||
<span className="text-slate-500 italic">
|
||||
{t('subtasks.delegateRunningTool', { tool: node.currentTool })}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{node.text}
|
||||
{running && (
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
<div className="pl-4 pr-2 pb-2 space-y-1.5">
|
||||
{node.children.map((c) => <ConsoleCard key={c.delegateRunId} node={c} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* delegate ライブコンソール: 実行中の delegate サブエージェントの出力を
|
||||
* チャット欄に専用枠でストリーム表示する。streams が空なら何も出さない。
|
||||
*
|
||||
* 表示は status==='running' の run だけに絞る(delegate は直列実行なので
|
||||
* 走っているのは1本+その入れ子のみ)。完了した run はチャットに溜めず、
|
||||
* 履歴は「概要>サブ実行」が担う(ライブ=チャット / 履歴=概要 の役割分担)。
|
||||
* これで 50 件連続スイープでもカードが積み上がらない。
|
||||
*/
|
||||
export function DelegateLiveConsole({ streams }: { streams: Record<string, DelegateStreamEntry> }) {
|
||||
const running = Object.fromEntries(
|
||||
Object.entries(streams).filter(([, e]) => e.status === 'running'),
|
||||
);
|
||||
const tree = buildTree(running);
|
||||
if (tree.length === 0) return null;
|
||||
return (
|
||||
<div className="max-w-[85%] w-full space-y-1.5">
|
||||
{tree.map((n) => <ConsoleCard key={n.delegateRunId} node={n} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -105,9 +105,18 @@ interface MovementGroupExpandedProps {
|
||||
isRunning: boolean;
|
||||
animatingIdx: number;
|
||||
startIdx: number;
|
||||
/**
|
||||
* inner の ChatMessage に渡す画像ベース URL の任意上書き。
|
||||
* 未指定なら ChatMessage 側の既定(local raw URL)にフォールバックするため
|
||||
* 本体(ChatPane)の呼び出しは従来どおり。共有ビューだけ
|
||||
* `/api/shared/:token/files/raw?path=` を渡して画像参照を差し替える。
|
||||
*/
|
||||
imageBaseUrl?: string;
|
||||
/** 共有ビューで添付チップ(input/ 配下・共有 API 非配信)を隠す。ChatMessage に委譲。 */
|
||||
hideAttachments?: boolean;
|
||||
}
|
||||
|
||||
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx }: MovementGroupExpandedProps) {
|
||||
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, imageBaseUrl, hideAttachments }: MovementGroupExpandedProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { movementName, summary, inner } = item;
|
||||
@@ -187,6 +196,8 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx }:
|
||||
key={`c-${b.comment.id}`}
|
||||
comment={b.comment}
|
||||
taskId={taskId}
|
||||
imageBaseUrl={imageBaseUrl}
|
||||
hideAttachments={hideAttachments}
|
||||
isStaleThinking={isThinkingComment(b.comment) && (startIdx + b.origIdx) !== animatingIdx}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ToolRequestApproval — the inline Approve/Deny card shown
|
||||
* in chat when the agent paused on a RequestTool. Branching covered:
|
||||
* - no pending requests → renders nothing
|
||||
* - non-pending / non-"requested" → filtered out
|
||||
* - pending "requested" rows → one card per tool, with reason + movement
|
||||
* - Approve / Deny buttons → call decideToolRequest with the right args
|
||||
* - mutation error → error row appears
|
||||
* Network (fetchToolRequests / decideToolRequest) is fully mocked; i18n uses the
|
||||
* real instance (auto-init on import) so labels resolve.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import '../../i18n';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import type { ToolRequest } from '../../api';
|
||||
import * as api from '../../api';
|
||||
import { ToolRequestApproval } from './ToolRequestApproval';
|
||||
|
||||
vi.mock('../../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchToolRequests: vi.fn(),
|
||||
decideToolRequest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedFetch = vi.mocked(api.fetchToolRequests);
|
||||
const mockedDecide = vi.mocked(api.decideToolRequest);
|
||||
|
||||
function req(overrides: Partial<ToolRequest> = {}): ToolRequest {
|
||||
return {
|
||||
id: 'req-1',
|
||||
taskId: '7',
|
||||
jobId: 'job-1',
|
||||
spaceId: null,
|
||||
pieceName: 'chat',
|
||||
movementName: 'execute',
|
||||
toolName: 'WebSearch',
|
||||
reason: 'need to look something up',
|
||||
category: 'requested',
|
||||
status: 'pending',
|
||||
grantScope: null,
|
||||
decidedBy: null,
|
||||
createdAt: '2026-06-25T00:00:00Z',
|
||||
decidedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ToolRequestApproval', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedDecide.mockReset();
|
||||
});
|
||||
|
||||
it('renders nothing when there are no requests', async () => {
|
||||
mockedFetch.mockResolvedValue([]);
|
||||
const { container } = renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith(7));
|
||||
expect(container.querySelector('[data-testid="tool-request-approval"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('filters out non-pending and non-"requested" rows', async () => {
|
||||
mockedFetch.mockResolvedValue([
|
||||
req({ id: 'a', status: 'approved' }),
|
||||
req({ id: 'b', category: 'blocked' }),
|
||||
]);
|
||||
const { container } = renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
|
||||
// both rows excluded → no card
|
||||
expect(container.querySelector('[data-testid="tool-request-approval"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a card per pending requested tool with reason + movement', async () => {
|
||||
mockedFetch.mockResolvedValue([
|
||||
req({ id: 'r1', toolName: 'WebSearch', reason: 'search the web', movementName: 'execute' }),
|
||||
req({ id: 'r2', toolName: 'Bash', reason: null, movementName: 'verify' }),
|
||||
]);
|
||||
renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
|
||||
|
||||
expect(await screen.findByTestId('tool-request-WebSearch')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('tool-request-Bash')).toBeInTheDocument();
|
||||
// tool name surfaces in the title (i18n interpolates {{tool}})
|
||||
expect(screen.getByText(/WebSearch/)).toBeInTheDocument();
|
||||
// reason shown for the one that has it
|
||||
expect(screen.getByText(/search the web/)).toBeInTheDocument();
|
||||
// movement label always shown
|
||||
expect(screen.getByText('movement: execute')).toBeInTheDocument();
|
||||
expect(screen.getByText('movement: verify')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Approve calls decideToolRequest with approve + the request id', async () => {
|
||||
mockedFetch.mockResolvedValue([req({ id: 'r1', toolName: 'WebSearch' })]);
|
||||
mockedDecide.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
|
||||
|
||||
const approveBtn = await screen.findByTestId('tool-request-approve-WebSearch');
|
||||
await user.click(approveBtn);
|
||||
|
||||
await waitFor(() => expect(mockedDecide).toHaveBeenCalledWith(7, 'r1', 'approve'));
|
||||
});
|
||||
|
||||
it('Deny calls decideToolRequest with deny + the request id', async () => {
|
||||
mockedFetch.mockResolvedValue([req({ id: 'r9', toolName: 'Bash' })]);
|
||||
mockedDecide.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
|
||||
|
||||
const denyBtn = await screen.findByTestId('tool-request-deny-Bash');
|
||||
await user.click(denyBtn);
|
||||
|
||||
await waitFor(() => expect(mockedDecide).toHaveBeenCalledWith(7, 'r9', 'deny'));
|
||||
});
|
||||
|
||||
it('shows an error row when the decide mutation rejects', async () => {
|
||||
mockedFetch.mockResolvedValue([req({ id: 'r1', toolName: 'WebSearch' })]);
|
||||
mockedDecide.mockRejectedValue(new Error('forbidden'));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ToolRequestApproval taskId={7} poll={false} />);
|
||||
|
||||
const approveBtn = await screen.findByTestId('tool-request-approve-WebSearch');
|
||||
await user.click(approveBtn);
|
||||
|
||||
expect(await screen.findByTestId('tool-request-error')).toHaveTextContent('forbidden');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchToolRequests, decideToolRequest } from '../../api';
|
||||
|
||||
/**
|
||||
* Inline approval card shown in the chat when the agent is paused waiting for
|
||||
* the user to approve/deny a tool it requested (RequestTool). Polls the task's
|
||||
* tool requests while enabled and renders an approve/deny pair per pending row.
|
||||
* Deciding re-queues the paused job, so the chat resumes on its own.
|
||||
*/
|
||||
export function ToolRequestApproval({ taskId, poll }: { taskId: number; poll: boolean }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Always fetch once when a task is open (so a pending approval card shows
|
||||
// immediately on load); poll only while the job is active/paused.
|
||||
const { data: requests = [] } = useQuery({
|
||||
queryKey: ['tool-requests', taskId],
|
||||
queryFn: () => fetchToolRequests(taskId),
|
||||
refetchInterval: poll ? 3000 : false,
|
||||
});
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ reqId, decision }: { reqId: string; decision: 'approve' | 'deny' }) =>
|
||||
decideToolRequest(taskId, reqId, decision),
|
||||
onSuccess: () => {
|
||||
// Refresh the request list (card disappears) and the task/job state so
|
||||
// ChatPane's jobStatus flips off waiting_human immediately — otherwise the
|
||||
// resume + stream reconnect lag until the next poll. Keys must match the
|
||||
// app's actual query keys (see useTaskDetail / useTaskOperations).
|
||||
qc.invalidateQueries({ queryKey: ['tool-requests', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTask', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pending = requests.filter((r) => r.status === 'pending' && r.category === 'requested');
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2" data-testid="tool-request-approval">
|
||||
{pending.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid={`tool-request-${r.toolName}`}
|
||||
className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm dark:border-amber-700/60 dark:bg-amber-900/20"
|
||||
>
|
||||
<div className="font-medium text-amber-900 dark:text-amber-200">
|
||||
{t('toolRequest.title', { tool: r.toolName })}
|
||||
</div>
|
||||
{r.reason && (
|
||||
<div className="mt-1 text-amber-800/90 dark:text-amber-200/80">
|
||||
{t('toolRequest.reason')}: {r.reason}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 text-xs text-amber-700/70 dark:text-amber-300/60">movement: {r.movementName}</div>
|
||||
{decide.isError && (
|
||||
<div className="mt-1 text-xs text-red-700 dark:text-red-300" data-testid="tool-request-error">
|
||||
{t('toolRequest.failed')}: {(decide.error as Error)?.message ?? ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`tool-request-approve-${r.toolName}`}
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ reqId: r.id, decision: 'approve' })}
|
||||
className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{t('toolRequest.approve')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`tool-request-deny-${r.toolName}`}
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ reqId: r.id, decision: 'deny' })}
|
||||
className="rounded-md border border-stone-300 bg-white px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:hover:bg-stone-700"
|
||||
>
|
||||
{t('toolRequest.deny')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -216,9 +216,9 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
{/* Workspace mode (永続/一時的) + スペース選択 */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">ワークスペース</label>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.modeLabel')}</label>
|
||||
<div className="inline-flex rounded-lg border border-slate-200 p-0.5">
|
||||
{([['persistent', '永続(既定)'], ['ephemeral', '一時的']] as const).map(([mode, label]) => {
|
||||
{([['persistent', t('workspace.persistent')], ['ephemeral', t('workspace.ephemeral')]] as const).map(([mode, label]) => {
|
||||
const active = (form.workspaceMode ?? 'persistent') === mode;
|
||||
return (
|
||||
<button
|
||||
@@ -235,16 +235,21 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
})}
|
||||
</div>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
永続=ワークスペースに蓄積/一時的=使い捨て
|
||||
{t('workspace.modeHint')}
|
||||
</p>
|
||||
{(form.workspaceMode ?? 'persistent') === 'ephemeral' && (
|
||||
<p className="text-2xs text-amber-600 mt-1" data-testid="ephemeral-warning">
|
||||
{t('workspace.ephemeralWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* スペース: 固定 or ピッカー(既定=個人) */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="block text-2xs text-slate-500 mb-1">ワークスペース</label>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.spaceLabel')}</label>
|
||||
{initialSpaceId ? (
|
||||
<div className="px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs text-slate-700 bg-slate-50 truncate">
|
||||
{fixedSpace?.title ?? 'このワークスペース'}
|
||||
{fixedSpace?.title ?? t('workspace.thisWorkspace')}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
@@ -252,7 +257,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
onChange={e => setSelectedSpaceId(e.target.value || undefined)}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">個人ワークスペース(既定)</option>
|
||||
<option value="">{t('workspace.personalDefault')}</option>
|
||||
{sortedSpaces
|
||||
.filter(s => s.kind === 'case')
|
||||
.map(s => (
|
||||
@@ -327,7 +332,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.profile')}</label>
|
||||
<select
|
||||
value={form.profile}
|
||||
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value }))}
|
||||
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value as CreateLocalTaskInput['profile'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['auto', 'auto'], ['fast', 'fast'], ['quality', 'quality']].map(([v, l]) => (
|
||||
@@ -339,7 +344,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.priority')}</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value }))}
|
||||
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value as CreateLocalTaskInput['priority'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['low', 'low'], ['medium', 'medium'], ['high', 'high']].map(([v, l]) => (
|
||||
@@ -355,7 +360,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.outputFormat')}</label>
|
||||
<select
|
||||
value={form.outputFormat}
|
||||
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value }))}
|
||||
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value as CreateLocalTaskInput['outputFormat'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['markdown', 'markdown'], ['text', 'text'], ['json', 'json']].map(([v, l]) => (
|
||||
@@ -367,7 +372,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.askPolicy')}</label>
|
||||
<select
|
||||
value={form.askPolicy}
|
||||
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value }))}
|
||||
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value as CreateLocalTaskInput['askPolicy'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="low">{t('advanced.askLow')}</option>
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useSidePanelLayout } from '../../hooks/useSidePanelLayout';
|
||||
import { VerticalResizeHandle } from '../layout/VerticalResizeHandle';
|
||||
import { SideInfoPanel } from './SideInfoPanel';
|
||||
|
||||
interface Props {
|
||||
/** TaskListPanel または RailPanel を含む上半分。 */
|
||||
upper: React.ReactNode;
|
||||
activeWidgetSlug?: string;
|
||||
onActiveWidgetSlugChange?: (slug: string) => void;
|
||||
/** rail/mobile 等の狭い viewport で default を collapsed にしたい場合に指定。 */
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
let _idSeq = 0;
|
||||
|
||||
export function TaskListWithSidePanel({
|
||||
upper,
|
||||
activeWidgetSlug,
|
||||
onActiveWidgetSlugChange,
|
||||
defaultCollapsed,
|
||||
}: Props) {
|
||||
const { listHeightPct, setListHeightPct, collapsed, toggleCollapsed, resetHeight } = useSidePanelLayout();
|
||||
const initOverrideRef = useRef<boolean>(false);
|
||||
if (defaultCollapsed && !initOverrideRef.current && localStorage.getItem('dashboard.collapsed') === null) {
|
||||
initOverrideRef.current = true;
|
||||
toggleCollapsed();
|
||||
}
|
||||
const [parentId] = useState(() => `tlspl-${++_idSeq}`);
|
||||
|
||||
const upperFlex = collapsed ? '1 1 auto' : `0 0 ${listHeightPct}%`;
|
||||
const lowerFlex = collapsed ? '0 0 auto' : `0 0 ${100 - listHeightPct}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-side-panel-parent={parentId}
|
||||
className="flex flex-col h-full min-h-0 overflow-hidden"
|
||||
>
|
||||
<div style={{ flex: upperFlex, minHeight: 0 }} className="overflow-hidden">
|
||||
{upper}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<VerticalResizeHandle
|
||||
parentSelector={`[data-side-panel-parent="${parentId}"]`}
|
||||
onResize={setListHeightPct}
|
||||
onResizeEnd={setListHeightPct}
|
||||
onReset={resetHeight}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: lowerFlex, minHeight: collapsed ? 'auto' : 0 }} className="overflow-hidden border-t border-hairline">
|
||||
<SideInfoPanel
|
||||
activeSlug={activeWidgetSlug}
|
||||
onActiveSlugChange={onActiveWidgetSlugChange}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={toggleCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ContextUsageGaugeProps {
|
||||
promptTokens?: number | null;
|
||||
limitTokens?: number | null;
|
||||
jobStatus?: string;
|
||||
/**
|
||||
* compact: 入力欄の直上に常時表示する低プロファイルのバー。概要タブのカード型
|
||||
* (既定)と同じ色・比率ロジックを共有しつつ、薄い 1 行表示にする。
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
@@ -29,16 +36,38 @@ function pickLabel(jobStatus: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus }: ContextUsageGaugeProps) {
|
||||
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus, compact }: ContextUsageGaugeProps) {
|
||||
const { t } = useTranslation('detail');
|
||||
if (!limitTokens || limitTokens <= 0) return null;
|
||||
|
||||
const tokens = typeof promptTokens === 'number' ? promptTokens : 0;
|
||||
const awaiting = typeof promptTokens !== 'number';
|
||||
const ratio = Math.min(1, Math.max(0, tokens / limitTokens));
|
||||
const percent = Math.round(ratio * 100);
|
||||
const remaining = Math.max(0, limitTokens - tokens);
|
||||
const colorClass = pickColorClass(ratio);
|
||||
const label = pickLabel(jobStatus);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 text-2xs text-slate-500 tabular-nums"
|
||||
title={`${formatNumber(tokens)} / ${formatNumber(limitTokens)} tokens`}
|
||||
aria-label={t('context.ariaLabel', { remaining: formatNumber(remaining), percent })}
|
||||
>
|
||||
<div className="h-1.5 flex-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${colorClass} transition-[width] duration-300 ease-out`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0">
|
||||
{awaiting ? t('context.awaiting') : t('context.remaining', { remaining: formatNumber(remaining), percent })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { DetailTabId } from '../../lib/urlState';
|
||||
import { shareTask, unshareTask } from '../../api';
|
||||
import { tabAppearClass } from './detailTabs';
|
||||
import { showHeaderActions } from './detail-readonly';
|
||||
|
||||
interface Tab { id: DetailTabId; labelKey: string; }
|
||||
|
||||
@@ -30,6 +31,9 @@ interface DetailHeaderProps {
|
||||
/** Click handler for the Continue button. When undefined, the button is
|
||||
* hidden entirely (e.g., shared/read-only views). */
|
||||
onContinue?: () => void;
|
||||
/** When true, hide all mutating actions (share / continue). Delete lives in
|
||||
* the panel footer and is gated there. Default false = unchanged. */
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export function ShareButton({ taskId, shareToken, onShareChange, testid }: { taskId: number; shareToken: string | null; onShareChange?: () => void; testid?: string }) {
|
||||
@@ -169,8 +173,9 @@ export function ContinueButton({ latestJobStatus, onClick, testid }: { latestJob
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPending, onTabChange, onClose, detailWidth, onWidthToggle, taskId, shareToken, onShareChange, latestJobStatus, onContinue }: DetailHeaderProps) {
|
||||
export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPending, onTabChange, onClose, detailWidth, onWidthToggle, taskId, shareToken, onShareChange, latestJobStatus, onContinue, readonly = false }: DetailHeaderProps) {
|
||||
const { t } = useTranslation('detail');
|
||||
const actionsVisible = showHeaderActions(readonly);
|
||||
// Mobile (< sm) hides the close button and tab bar because App.tsx
|
||||
// renders its own mobile-level top tab bar with the same controls.
|
||||
// Two close buttons / two tab bars on iPhone was visually redundant.
|
||||
@@ -185,13 +190,13 @@ export function DetailHeader({ title, subtitle, tabs, activeTab, tabTransitionPe
|
||||
now icon-only (32px) so it fits next to the title instead of
|
||||
occupying its own row. */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onContinue && taskId != null && (
|
||||
{actionsVisible && onContinue && taskId != null && (
|
||||
<ContinueButton
|
||||
latestJobStatus={latestJobStatus ?? null}
|
||||
onClick={onContinue}
|
||||
/>
|
||||
)}
|
||||
{taskId != null && (
|
||||
{actionsVisible && taskId != null && (
|
||||
<ShareButton
|
||||
taskId={taskId}
|
||||
shareToken={shareToken ?? null}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { ConsoleTab } from './tabs/ConsoleTab';
|
||||
import { BrowserSessionPanel } from '../browser/BrowserSessionPanel';
|
||||
import { useAuthState } from '../../App';
|
||||
import type { SubtaskFilePreviewHandler } from './tabs/SubtasksPanel';
|
||||
import { showVisibilityEdit, showDelete } from './detail-readonly';
|
||||
import { sharingPreview, visibilityTooltip } from '../../lib/sharingScope';
|
||||
|
||||
interface LocalDetailPanelProps {
|
||||
task: LocalTask | null;
|
||||
@@ -51,6 +53,10 @@ interface LocalDetailPanelProps {
|
||||
* the active tab body. Used by the space chat single-tab layout where the
|
||||
* tab bar lives outside this panel. Default false = unchanged (Tasks page). */
|
||||
headerless?: boolean;
|
||||
/** When true, render a read-only mirror: no title/visibility/feedback edit,
|
||||
* no delete/share/continue actions, no dependence on useAuthState for edit
|
||||
* gating. Used by the public SharedView. Default false = unchanged. */
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +65,7 @@ export function LocalDetailPanel({
|
||||
loading, detailTab, detailWidth, showWidthToggle,
|
||||
onTabChange, onWidthToggle, onClose, onDelete, onSectionChange, onNavigate, onPreview, onViewFullLog,
|
||||
onRefresh, isRefreshing, fileManagement, subtaskActivities, onSubtaskFilePreview,
|
||||
shareToken, onShareChange, headerless = false,
|
||||
shareToken, onShareChange, headerless = false, readonly = false,
|
||||
}: LocalDetailPanelProps) {
|
||||
const { t } = useTranslation('detail');
|
||||
// Deferred tab id for content rendering. The tab indicator (DetailHeader)
|
||||
@@ -80,9 +86,13 @@ export function LocalDetailPanel({
|
||||
const authState = useAuthState();
|
||||
const currentUserId = authState.mode === 'authenticated' ? authState.user.id : null;
|
||||
const currentUserRole = authState.mode === 'authenticated' ? authState.user.role : null;
|
||||
const canEditVisibility = task
|
||||
? (currentUserRole === 'admin' || (currentUserId !== null && task.ownerId === currentUserId))
|
||||
: false;
|
||||
// readonly(共有ページ)では認証状態に依存せず、可視性編集を常に隠す。
|
||||
const canEditVisibility = showVisibilityEdit(
|
||||
readonly,
|
||||
task
|
||||
? (currentUserRole === 'admin' || (currentUserId !== null && task.ownerId === currentUserId))
|
||||
: false,
|
||||
);
|
||||
const { data: orgs = [] } = useQuery({
|
||||
queryKey: ['my-orgs'],
|
||||
queryFn: fetchMyOrgs,
|
||||
@@ -152,6 +162,7 @@ export function LocalDetailPanel({
|
||||
onShareChange={onShareChange}
|
||||
latestJobStatus={task?.latestJob?.status ?? null}
|
||||
onContinue={task?.latestJob ? () => setContinueOpen(true) : undefined}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)}
|
||||
{continueOpen && task?.latestJob && (
|
||||
@@ -190,7 +201,7 @@ export function LocalDetailPanel({
|
||||
{editingVisibility && (
|
||||
<div className="mb-3 p-2.5 border border-hairline rounded-md bg-canvas text-xs">
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<label className="flex items-center gap-1">
|
||||
<label className="flex items-center gap-1" title={visibilityTooltip('private')}>
|
||||
<input
|
||||
type="radio"
|
||||
checked={editVisibility === 'private'}
|
||||
@@ -198,7 +209,7 @@ export function LocalDetailPanel({
|
||||
/>
|
||||
🔒 {t('visibility.private')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<label className="flex items-center gap-1" title={visibilityTooltip('org')}>
|
||||
<input
|
||||
type="radio"
|
||||
checked={editVisibility === 'org'}
|
||||
@@ -210,7 +221,7 @@ export function LocalDetailPanel({
|
||||
/>
|
||||
🏢 {t('visibility.org')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<label className="flex items-center gap-1" title={visibilityTooltip('public')}>
|
||||
<input
|
||||
type="radio"
|
||||
checked={editVisibility === 'public'}
|
||||
@@ -234,6 +245,36 @@ export function LocalDetailPanel({
|
||||
{editVisibility === 'org' && orgs.length === 0 && (
|
||||
<div className="mt-1 text-2xs text-slate-400">{t('visibility.orgLoginHint')}</div>
|
||||
)}
|
||||
{(() => {
|
||||
const orgName = editVisibility === 'org'
|
||||
? (orgs.find(o => o.orgId === editScopeOrgId)?.orgName ?? orgs[0]?.orgName ?? null)
|
||||
: null;
|
||||
const preview = sharingPreview(editVisibility, orgName);
|
||||
return (
|
||||
<div
|
||||
data-testid="visibility-sharing-preview"
|
||||
className="mt-2.5 p-2 rounded-md bg-surface border border-hairline/70 text-2xs"
|
||||
>
|
||||
<div className="text-slate-600">👥 {preview.audience}</div>
|
||||
{editVisibility !== 'private' && (
|
||||
<>
|
||||
<ul className="mt-1.5 space-y-0.5">
|
||||
{preview.items.map((it) => (
|
||||
<li key={it.label} className="flex items-center gap-1">
|
||||
{it.shared
|
||||
? <span className="text-green-600" aria-hidden>✅</span>
|
||||
: <span className="text-slate-400" aria-hidden>🔒</span>}
|
||||
<span className={it.shared ? 'text-slate-700' : 'text-slate-500'}>{it.label}</span>
|
||||
{!it.shared && <span className="text-slate-400">{t('sharingPreview.notShared')}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-1.5 text-slate-500">👁 {preview.accessNote}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{editError && <div className="mt-1 text-2xs text-red-600">{editError}</div>}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
@@ -256,7 +297,7 @@ export function LocalDetailPanel({
|
||||
{task?.latestJob?.status === 'waiting_human' && task?.latestJob?.waitReason === 'browser_login' && (
|
||||
<BrowserSessionPanel />
|
||||
)}
|
||||
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} />}
|
||||
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} readonly={readonly} />}
|
||||
{deferredDetailTab === 'activity' && <ProgressTab task={task} onViewFullLog={onViewFullLog} subtaskActivities={subtaskActivities} />}
|
||||
{deferredDetailTab === 'files' && <FilesTab section={section} currentPath={currentPath} entries={entries} pathSegments={pathSegments} taskId={taskId} onSectionChange={onSectionChange} onNavigate={onNavigate} onPreview={onPreview} onRefresh={onRefresh} isRefreshing={isRefreshing} management={fileManagement} />}
|
||||
{deferredDetailTab === 'trace' && <TraceTab taskId={taskId} />}
|
||||
@@ -265,7 +306,7 @@ export function LocalDetailPanel({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!loading && task && (
|
||||
{!loading && task && showDelete(readonly) && (
|
||||
<div className="flex-shrink-0 border-t border-hairline bg-canvas px-3 py-2.5">
|
||||
<div className="flex gap-2 items-center">
|
||||
{onDelete && !isActiveJob ? (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ReflectionBadge — the "🧠 Learned N things" pill on the
|
||||
* Overview tab. The badge only appears when the latest reflection for a task
|
||||
* actually changed something. Verifies: hidden when no reflection; hidden when
|
||||
* outcome is abstained/failed; hidden when 0 memory changes and no piece edit;
|
||||
* shown with correct singular/plural label; shows "+ piece edit" suffix; links
|
||||
* to the memory-learning settings section anchored at the snapshot.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import type { LatestReflectionForTask } from '../../api';
|
||||
import * as api from '../../api';
|
||||
import { ReflectionBadge } from './ReflectionBadge';
|
||||
|
||||
// Mock just the one network call the badge makes.
|
||||
vi.mock('../../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api')>();
|
||||
return { ...actual, getLatestReflectionForTask: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedGet = vi.mocked(api.getLatestReflectionForTask);
|
||||
|
||||
function reflection(overrides: Partial<LatestReflectionForTask> = {}): LatestReflectionForTask {
|
||||
return {
|
||||
snapshotId: 'snap-123',
|
||||
outcome: 'applied',
|
||||
memoryChanges: 2,
|
||||
pieceEdited: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReflectionBadge', () => {
|
||||
beforeEach(() => {
|
||||
mockedGet.mockReset();
|
||||
});
|
||||
|
||||
it('renders nothing when there is no reflection', async () => {
|
||||
mockedGet.mockResolvedValue(null);
|
||||
const { container } = renderWithProviders(<ReflectionBadge taskId={1} />);
|
||||
// Give the (resolved-null) query a tick; badge must stay empty.
|
||||
await waitFor(() => expect(mockedGet).toHaveBeenCalled());
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when the outcome is abstained', async () => {
|
||||
mockedGet.mockResolvedValue(reflection({ outcome: 'abstained', memoryChanges: 3 }));
|
||||
const { container } = renderWithProviders(<ReflectionBadge taskId={2} />);
|
||||
await waitFor(() => expect(mockedGet).toHaveBeenCalled());
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when 0 memory changes and no piece edit', async () => {
|
||||
mockedGet.mockResolvedValue(reflection({ memoryChanges: 0, pieceEdited: false }));
|
||||
const { container } = renderWithProviders(<ReflectionBadge taskId={3} />);
|
||||
await waitFor(() => expect(mockedGet).toHaveBeenCalled());
|
||||
expect(container.querySelector('a')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the pluralized label when reflection changed multiple things', async () => {
|
||||
mockedGet.mockResolvedValue(reflection({ memoryChanges: 2 }));
|
||||
renderWithProviders(<ReflectionBadge taskId={4} />);
|
||||
const link = await screen.findByRole('link');
|
||||
expect(link).toHaveTextContent('🧠 Learned 2 things');
|
||||
expect(link).not.toHaveTextContent('piece edit');
|
||||
});
|
||||
|
||||
it('uses the singular "thing" for exactly one memory change', async () => {
|
||||
mockedGet.mockResolvedValue(reflection({ memoryChanges: 1 }));
|
||||
renderWithProviders(<ReflectionBadge taskId={5} />);
|
||||
const link = await screen.findByRole('link');
|
||||
expect(link).toHaveTextContent('Learned 1 thing');
|
||||
expect(link).not.toHaveTextContent('things');
|
||||
});
|
||||
|
||||
it('appends "+ piece edit" and links to the snapshot when a piece was edited', async () => {
|
||||
mockedGet.mockResolvedValue(reflection({ memoryChanges: 0, pieceEdited: true }));
|
||||
renderWithProviders(<ReflectionBadge taskId={6} />);
|
||||
const link = await screen.findByRole('link');
|
||||
expect(link).toHaveTextContent('Learned 0 things + piece edit');
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'?page=settings§ion=memory-learning#snapshot-snap-123',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
showVisibilityEdit,
|
||||
showTitleEdit,
|
||||
showFeedback,
|
||||
showMissionEdit,
|
||||
showHeaderActions,
|
||||
showDelete,
|
||||
} from './detail-readonly';
|
||||
|
||||
describe('detail read-only predicates', () => {
|
||||
it('hides all edit affordances when read-only', () => {
|
||||
expect(showVisibilityEdit(true, true)).toBe(false);
|
||||
expect(showTitleEdit(true)).toBe(false);
|
||||
expect(showFeedback(true)).toBe(false);
|
||||
expect(showMissionEdit(true)).toBe(false);
|
||||
expect(showHeaderActions(true)).toBe(false);
|
||||
expect(showDelete(true)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps authed (readonly=false) behavior unchanged', () => {
|
||||
// 可視性編集は権限次第(従来どおり)
|
||||
expect(showVisibilityEdit(false, true)).toBe(true);
|
||||
expect(showVisibilityEdit(false, false)).toBe(false);
|
||||
// 残りは authed では常に表示
|
||||
expect(showTitleEdit(false)).toBe(true);
|
||||
expect(showFeedback(false)).toBe(true);
|
||||
expect(showMissionEdit(false)).toBe(true);
|
||||
expect(showHeaderActions(false)).toBe(true);
|
||||
expect(showDelete(false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// 詳細パネルの read-only 分岐(純ロジック)。
|
||||
// 共有ページ(公開・認証なし)では編集系 UI を一切出さない。本体(認証版)は
|
||||
// readonly 既定 false なので従来どおり。各コンポーネントはこの述語を使い、
|
||||
// readonly のとき mutation を呼ぶ要素自体を描画しない。
|
||||
|
||||
/** 可視性編集(private/org/public 切替)を出すか。readonly では常に隠す。 */
|
||||
export function showVisibilityEdit(readonly: boolean, canEditVisibility: boolean): boolean {
|
||||
return !readonly && canEditVisibility;
|
||||
}
|
||||
|
||||
/** タイトル編集・再生成 UI を出すか。readonly では隠す。 */
|
||||
export function showTitleEdit(readonly: boolean): boolean {
|
||||
return !readonly;
|
||||
}
|
||||
|
||||
/** フィードバック投稿 UI を出すか。readonly では隠す。 */
|
||||
export function showFeedback(readonly: boolean): boolean {
|
||||
return !readonly;
|
||||
}
|
||||
|
||||
/** Mission Brief の編集 UI を出すか。readonly では閲覧のみ。 */
|
||||
export function showMissionEdit(readonly: boolean): boolean {
|
||||
return !readonly;
|
||||
}
|
||||
|
||||
/** ヘッダーの破壊的/共有/再実行アクション(削除・共有・Continue)を出すか。 */
|
||||
export function showHeaderActions(readonly: boolean): boolean {
|
||||
return !readonly;
|
||||
}
|
||||
|
||||
/** フッターの削除ボタンを出すか。readonly では隠す。 */
|
||||
export function showDelete(readonly: boolean): boolean {
|
||||
return !readonly;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { POLLING } from '../../../lib/constants.js';
|
||||
import { fetchDelegateRuns, fetchDelegateRunTimeline, type TraceEventLite } from '../../../api';
|
||||
import { buildDelegateRunTree, delegateStatusBadge, formatElapsed, type DelegateRunNode, type DelegateRun } from '../../../lib/delegateRuns';
|
||||
import { useNow } from '../../../hooks/useNow';
|
||||
import { summarizeTraceEvent } from '../../../lib/traceEvent';
|
||||
|
||||
function EventLine({ event }: { event: TraceEventLite }) {
|
||||
const summary = summarizeTraceEvent(event);
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5 py-0.5 font-mono text-[10px] text-slate-500">
|
||||
<span className="shrink-0 text-slate-400">{new Date(event.ts).toLocaleTimeString()}</span>
|
||||
<span className="shrink-0 font-semibold text-slate-600">{event.kind}</span>
|
||||
{summary && <span className="truncate text-slate-400">{summary}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunCard({ taskId, node, indent = 0 }: { taskId: number; node: DelegateRunNode; indent?: number }) {
|
||||
const { t } = useTranslation('detail');
|
||||
const [open, setOpen] = useState(false);
|
||||
const badge = delegateStatusBadge(node.status);
|
||||
const running = node.status === 'running';
|
||||
// 実行中カードのみ 1 秒刻みで経過を更新(ネットワーク不要のローカルタイマー)。
|
||||
const now = useNow(running);
|
||||
|
||||
const { data: events } = useQuery({
|
||||
queryKey: ['delegate-run-timeline', taskId, node.delegateRunId],
|
||||
queryFn: () => fetchDelegateRunTimeline(taskId, node.delegateRunId),
|
||||
enabled: open,
|
||||
// 開いていて実行中の間だけ自動更新。完了したら停止(done な run のイベントは不変)。
|
||||
refetchInterval: open && running ? POLLING.FAST : false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border border-slate-200 rounded-md mb-1.5 overflow-hidden"
|
||||
style={indent > 0 ? { marginLeft: `${indent * 16}px` } : undefined}
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-slate-50 transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className={`shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full ${badge.cls}`}>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
<span className="text-[13px] text-slate-800 font-medium truncate flex-1">
|
||||
{node.description || '(no description)'}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">
|
||||
depth {node.depth} · {node.toolCalls} tools · {formatElapsed(node.startTs, node.endTs, now)}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400 text-xs ml-1">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-slate-100 px-3 pb-2">
|
||||
{events && events.length > 0 ? (
|
||||
<div className="mt-1">
|
||||
{events.map((e) => <EventLine key={e.eventId} event={e} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[11px] text-slate-400">
|
||||
{events ? t('delegateRuns.eventsEmpty') : t('common:loading')}
|
||||
</div>
|
||||
)}
|
||||
{node.children.map((c) => (
|
||||
<RunCard key={c.delegateRunId} taskId={taskId} node={c} indent={indent + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DelegateRunsSection({ taskId }: { taskId: number }) {
|
||||
const { t } = useTranslation('detail');
|
||||
const { data: runs } = useQuery({
|
||||
queryKey: ['delegate-runs', taskId],
|
||||
queryFn: () => fetchDelegateRuns(taskId),
|
||||
// 実行中の run がある間は速く(FAST=5s)、無ければ通常(MEDIUM=10s)。
|
||||
// refetchIntervalInBackground は既定 false なのでタブ非表示時は自動停止。
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as DelegateRun[] | undefined;
|
||||
return data?.some((r) => r.status === 'running') ? POLLING.FAST : POLLING.MEDIUM;
|
||||
},
|
||||
});
|
||||
|
||||
const tree = buildDelegateRunTree(runs ?? []);
|
||||
if (tree.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-bold text-slate-800 mb-2">{t('subtasks.delegateSection')}</div>
|
||||
{tree.map((n) => (
|
||||
<RunCard key={n.delegateRunId} taskId={taskId} node={n} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { LinkifiedText } from '../../../lib/linkified-text';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OutputTabProps {
|
||||
outputPreviewName: string;
|
||||
outputPreviewContent: string;
|
||||
onViewFull: () => void;
|
||||
}
|
||||
|
||||
export function OutputTab({ outputPreviewName, outputPreviewContent, onViewFull }: OutputTabProps) {
|
||||
const { t } = useTranslation('detail');
|
||||
return (
|
||||
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className="font-bold text-[13px] text-slate-800">{t('output.title')}</div>
|
||||
{outputPreviewName && (
|
||||
<button onClick={onViewFull} className="text-2xs text-blue-600 font-bold hover:underline">{t('output.viewFull')}</button>
|
||||
)}
|
||||
</div>
|
||||
{outputPreviewName ? (
|
||||
<>
|
||||
<div className="text-2xs text-slate-400 mb-2 font-mono">{outputPreviewName}</div>
|
||||
{/* LinkifiedText turns inline `output/foo.md` references into
|
||||
clickable anchors that the OutputPreviewProvider opens in
|
||||
the preview pane. Plain `<pre>` rendering otherwise. */}
|
||||
<LinkifiedText
|
||||
as="pre"
|
||||
className="text-xs whitespace-pre-wrap bg-slate-50 rounded-xl p-3 min-h-[260px] max-h-[540px] overflow-auto border border-slate-100"
|
||||
text={outputPreviewContent.slice(0, 12000)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[13px] text-slate-500">{t('output.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for OverviewTab — the task summary tab. Verifies the title +
|
||||
* status badge + piece/priority chips render, the body shows (with the
|
||||
* "(no body)" fallback), the FeedbackPanel only appears for completed jobs, and
|
||||
* the "🧠 Learned N things" reflection badge surfaces only when the latest
|
||||
* reflection actually changed something. Network calls (reflection, delegate
|
||||
* runs) are mocked so the tab mounts in isolation.
|
||||
*/
|
||||
import '../../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../../test/render-helpers';
|
||||
import '../../../i18n';
|
||||
import type { LocalTask, LatestReflectionForTask } from '../../../api';
|
||||
import * as api from '../../../api';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
|
||||
vi.mock('../../../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../api')>();
|
||||
return {
|
||||
...actual,
|
||||
getLatestReflectionForTask: vi.fn().mockResolvedValue(null),
|
||||
fetchDelegateRuns: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedReflection = vi.mocked(api.getLatestReflectionForTask);
|
||||
|
||||
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
|
||||
return {
|
||||
id: 10,
|
||||
title: 'Quarterly review',
|
||||
body: 'Analyze last quarter performance.',
|
||||
pieceName: 'analysis',
|
||||
profile: 'default',
|
||||
outputFormat: 'markdown',
|
||||
askPolicy: 'auto',
|
||||
priority: 'high',
|
||||
state: 'open',
|
||||
workspacePath: null,
|
||||
createdAt: '2026-06-01T00:00:00Z',
|
||||
updatedAt: '2026-06-01T00:00:00Z',
|
||||
latestJob: { id: 'job-10', status: 'running' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function reflection(overrides: Partial<LatestReflectionForTask> = {}): LatestReflectionForTask {
|
||||
return { snapshotId: 's1', outcome: 'applied', memoryChanges: 3, pieceEdited: false, ...overrides };
|
||||
}
|
||||
|
||||
describe('OverviewTab', () => {
|
||||
it('renders the title, status badge and piece/priority chips', async () => {
|
||||
mockedReflection.mockResolvedValue(null);
|
||||
renderWithProviders(<OverviewTab task={makeTask()} />);
|
||||
expect(screen.getByText('Quarterly review')).toBeInTheDocument();
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
expect(screen.getByText('analysis')).toBeInTheDocument();
|
||||
expect(screen.getByText('high')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the body, and the (no body) fallback when empty', async () => {
|
||||
mockedReflection.mockResolvedValue(null);
|
||||
const { rerender } = renderWithProviders(<OverviewTab task={makeTask()} />);
|
||||
expect(screen.getByText('Analyze last quarter performance.')).toBeInTheDocument();
|
||||
rerender(<OverviewTab task={makeTask({ body: '' })} />);
|
||||
expect(screen.getByText('(no body)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the feedback panel while the job is still running', async () => {
|
||||
mockedReflection.mockResolvedValue(null);
|
||||
renderWithProviders(<OverviewTab task={makeTask({ latestJob: { id: 'j', status: 'running' } })} />);
|
||||
// FeedbackPanel returns null until the job is complete.
|
||||
expect(screen.queryByText('Feedback')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the feedback panel once the job has succeeded', async () => {
|
||||
mockedReflection.mockResolvedValue(null);
|
||||
renderWithProviders(<OverviewTab task={makeTask({ latestJob: { id: 'j', status: 'succeeded' } })} />);
|
||||
expect(screen.getByText('Feedback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the reflection badge when no reflection changed anything', async () => {
|
||||
mockedReflection.mockResolvedValue(null);
|
||||
renderWithProviders(<OverviewTab task={makeTask()} />);
|
||||
await waitFor(() => expect(mockedReflection).toHaveBeenCalled());
|
||||
expect(screen.queryByText(/Learned/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the "🧠 Learned N things" badge when reflection applied changes', async () => {
|
||||
mockedReflection.mockResolvedValue(reflection({ memoryChanges: 3 }));
|
||||
renderWithProviders(<OverviewTab task={makeTask()} />);
|
||||
expect(await screen.findByText(/Learned 3 things/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,15 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief, updateLocalTask, regenerateTaskTitle } from '../../../api';
|
||||
import { StatusBadge } from '../../shared/StatusBadge';
|
||||
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
|
||||
import { DelegateRunsSection } from './DelegateRunsSection';
|
||||
import { ContextUsageGauge } from '../ContextUsageGauge';
|
||||
import { ReflectionBadge } from '../ReflectionBadge';
|
||||
import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly';
|
||||
|
||||
const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '指示をよく理解していた', '速度が適切だった'];
|
||||
const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた'];
|
||||
|
||||
function FeedbackPanel({ task }: { task: LocalTask }) {
|
||||
function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
|
||||
const { t } = useTranslation('detail');
|
||||
const qc = useQueryClient();
|
||||
const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed';
|
||||
@@ -32,6 +34,29 @@ function FeedbackPanel({ task }: { task: LocalTask }) {
|
||||
});
|
||||
|
||||
if (!isComplete) return null;
|
||||
// read-only(共有ページ): 投稿/編集 UI は出さない。フィードバック未登録なら
|
||||
// 何も描画しない。登録済みでも change ボタン(mutation 経路)を出さない。
|
||||
if (readonly && !showFeedback(readonly)) {
|
||||
if (!hasFeedback) return null;
|
||||
return (
|
||||
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-700">{t('feedback.title')}</span>
|
||||
<span className={`text-lg ${task.feedbackRating === 'good' ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{task.feedbackRating === 'good' ? '👍' : '👎'}
|
||||
</span>
|
||||
</div>
|
||||
{task.feedbackTags && task.feedbackTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{task.feedbackTags.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{task.feedbackComment && <div className="mt-2 text-xs text-slate-500">{task.feedbackComment}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tags = rating === 'good' ? GOOD_TAGS : rating === 'bad' ? BAD_TAGS : [];
|
||||
const toggleTag = (tag: string) => {
|
||||
@@ -163,7 +188,7 @@ const MISSION_FIELDS: Array<{ key: keyof MissionBrief }> = [
|
||||
|
||||
const EMPTY_MISSION: MissionBrief = { goal: '', done: '', open: '', clarifications: '' };
|
||||
|
||||
function MissionCard({ task }: { task: LocalTask }) {
|
||||
function MissionCard({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
|
||||
const { t } = useTranslation('detail');
|
||||
const qc = useQueryClient();
|
||||
const current = task.missionBrief ?? EMPTY_MISSION;
|
||||
@@ -191,6 +216,8 @@ function MissionCard({ task }: { task: LocalTask }) {
|
||||
});
|
||||
|
||||
const isEmpty = !current.goal && !current.done && !current.open && !current.clarifications;
|
||||
// read-only(共有): 空の Mission Brief は編集導線が主目的なので丸ごと隠す。
|
||||
if (readonly && isEmpty) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-canvas border border-hairline rounded-md p-3.5">
|
||||
@@ -202,7 +229,7 @@ function MissionCard({ task }: { task: LocalTask }) {
|
||||
<span className="section-label">Mission Brief</span>
|
||||
<span className="text-[10px] text-slate-400">— {t('mission.pinnedMemo')}</span>
|
||||
</div>
|
||||
{!editing ? (
|
||||
{showMissionEdit(readonly) && !editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setDraft(current); setEditing(true); setError(null); }}
|
||||
@@ -278,7 +305,7 @@ function MissionCard({ task }: { task: LocalTask }) {
|
||||
* Mission Brief goal) during the run. A manual edit pins it (title_source =
|
||||
* 'user') so the agent never overwrites it afterwards.
|
||||
*/
|
||||
function TaskTitleRow({ task }: { task: LocalTask }) {
|
||||
function TaskTitleRow({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
|
||||
const { t } = useTranslation('detail');
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -351,6 +378,7 @@ function TaskTitleRow({ task }: { task: LocalTask }) {
|
||||
<div>
|
||||
<div className="group flex items-start justify-between gap-2">
|
||||
<div className="text-lg font-extrabold text-slate-900 break-words leading-tight min-w-0">{task.title}</div>
|
||||
{showTitleEdit(readonly) && (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -376,6 +404,7 @@ function TaskTitleRow({ task }: { task: LocalTask }) {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className="text-2xs text-red-600 mt-1">{error}</div>}
|
||||
</div>
|
||||
@@ -385,16 +414,17 @@ function TaskTitleRow({ task }: { task: LocalTask }) {
|
||||
interface OverviewTabProps {
|
||||
task: LocalTask;
|
||||
subtaskActivities?: SubtaskActivity[];
|
||||
readonly?: boolean;
|
||||
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
|
||||
}
|
||||
|
||||
export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: OverviewTabProps) {
|
||||
export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview, readonly = false }: OverviewTabProps) {
|
||||
const status = task.latestJob?.status ?? 'queued';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
<TaskTitleRow task={task} />
|
||||
<TaskTitleRow task={task} readonly={readonly} />
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<StatusBadge status={status} />
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>
|
||||
@@ -403,7 +433,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
|
||||
<div className="mt-3 text-[13px] text-slate-600 whitespace-pre-wrap leading-relaxed">{task.body || '(no body)'}</div>
|
||||
</div>
|
||||
|
||||
<MissionCard task={task} />
|
||||
<MissionCard task={task} readonly={readonly} />
|
||||
|
||||
<ContextUsageGauge
|
||||
promptTokens={task.latestJob?.contextPromptTokens}
|
||||
@@ -411,7 +441,7 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
|
||||
jobStatus={task.latestJob?.status}
|
||||
/>
|
||||
|
||||
<FeedbackPanel task={task} />
|
||||
<FeedbackPanel task={task} readonly={readonly} />
|
||||
|
||||
<ReflectionBadge taskId={task.id} />
|
||||
|
||||
@@ -425,6 +455,11 @@ export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: O
|
||||
onFilePreview={onSubtaskFilePreview}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* delegate サブ実行: SpawnSubTask の有無に関係なく表示(runが無ければ自己非表示)。
|
||||
SubtasksPanel は subtasks.length>0 でしかマウントされないため、delegate のみの
|
||||
タスクでも見えるよう独立して描画する。 */}
|
||||
<DelegateRunsSection taskId={task.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ProgressTab — the execution timeline + raw activity.log
|
||||
* view. Verifies the parsed-event count renders, the current-movement line
|
||||
* reflects job state (and its pending fallback), the empty timeline label shows
|
||||
* when there are no events, the raw activity.log body renders, and the
|
||||
* "view full log" control fires its callback.
|
||||
*/
|
||||
import '../../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../../test/render-helpers';
|
||||
import '../../../i18n';
|
||||
import type { LocalTask } from '../../../api';
|
||||
import * as taskDetailHooks from '../../../hooks/useTaskDetail';
|
||||
import { ProgressTab } from './ProgressTab';
|
||||
|
||||
// Control the activity.log content without touching the network.
|
||||
vi.mock('../../../hooks/useTaskDetail', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../hooks/useTaskDetail')>();
|
||||
return { ...actual, useLocalActivityLog: vi.fn() };
|
||||
});
|
||||
|
||||
const mockedHook = vi.mocked(taskDetailHooks.useLocalActivityLog);
|
||||
|
||||
function mockLog(data: string, isLoading = false) {
|
||||
// Only the .data + .isLoading fields are read by ProgressTab.
|
||||
mockedHook.mockReturnValue({ data, isLoading } as unknown as ReturnType<
|
||||
typeof taskDetailHooks.useLocalActivityLog
|
||||
>);
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
|
||||
return {
|
||||
id: 1,
|
||||
title: 'T',
|
||||
body: '',
|
||||
pieceName: 'chat',
|
||||
profile: 'default',
|
||||
outputFormat: 'markdown',
|
||||
askPolicy: 'auto',
|
||||
priority: 'normal',
|
||||
state: 'open',
|
||||
workspacePath: null,
|
||||
createdAt: '2026-06-01T00:00:00Z',
|
||||
updatedAt: '2026-06-01T00:00:00Z',
|
||||
latestJob: { id: 'job-1', status: 'running', currentMovement: 'analyze' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProgressTab', () => {
|
||||
it('renders the current movement line from the job state', () => {
|
||||
mockLog('');
|
||||
renderWithProviders(
|
||||
<ProgressTab task={makeTask({ latestJob: { id: 'j', status: 'running', currentMovement: 'analyze' } })} onViewFullLog={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText(/analyze/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty-timeline label when the log has no events', () => {
|
||||
mockLog('');
|
||||
renderWithProviders(<ProgressTab task={makeTask()} onViewFullLog={() => {}} />);
|
||||
expect(screen.getByText('No progress yet.')).toBeInTheDocument();
|
||||
// 0 events counter.
|
||||
expect(screen.getByText(/0 events/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the raw activity.log content in the pre block', () => {
|
||||
mockLog('hello from the activity log');
|
||||
const { container } = renderWithProviders(<ProgressTab task={makeTask()} onViewFullLog={() => {}} />);
|
||||
// The raw log is rendered verbatim inside the <pre> block (the timeline may
|
||||
// also echo parsed lines, so scope the assertion to the pre element).
|
||||
const pre = container.querySelector('pre');
|
||||
expect(pre).not.toBeNull();
|
||||
expect(pre!.textContent).toContain('hello from the activity log');
|
||||
});
|
||||
|
||||
it('fires onViewFullLog when the view-full control is clicked', async () => {
|
||||
mockLog('some log');
|
||||
const user = userEvent.setup();
|
||||
const onViewFullLog = vi.fn();
|
||||
renderWithProviders(<ProgressTab task={makeTask()} onViewFullLog={onViewFullLog} />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(onViewFullLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for SubtasksPanel — the sub-run progress panel shown on the
|
||||
* Overview tab. Verifies the header/progress fraction renders, the progress bar
|
||||
* width reflects completed/total, each subtask card shows its status badge +
|
||||
* title, and that the per-subtask activity/file queries stay dormant until a
|
||||
* card is expanded (lazy: enabled only when expanded).
|
||||
*/
|
||||
import '../../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../../test/render-helpers';
|
||||
import '../../../i18n';
|
||||
import type { SubtaskInfo } from '../../../api';
|
||||
import * as api from '../../../api';
|
||||
import { SubtasksPanel } from './SubtasksPanel';
|
||||
|
||||
// Card expansion fires these; mock so an expand never hits the network.
|
||||
vi.mock('../../../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../api')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchSubtaskActivity: vi.fn().mockResolvedValue(''),
|
||||
fetchSubtaskFiles: vi.fn().mockResolvedValue({ categories: {} }),
|
||||
};
|
||||
});
|
||||
|
||||
function subtask(overrides: Partial<SubtaskInfo> = {}): SubtaskInfo {
|
||||
return {
|
||||
id: 'job-sub-1',
|
||||
issueNumber: 7,
|
||||
status: 'succeeded',
|
||||
instruction: 'Summarize chapter one\nthen extract the key points',
|
||||
worktreePath: null,
|
||||
createdAt: '2026-06-01T00:00:00Z',
|
||||
updatedAt: '2026-06-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SubtasksPanel', () => {
|
||||
it('renders the header with the completed/total fraction', () => {
|
||||
renderWithProviders(
|
||||
<SubtasksPanel
|
||||
taskId={1}
|
||||
subtasks={[subtask()]}
|
||||
subtaskCount={2}
|
||||
subtaskCompleted={1}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Sub-runs')).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\/2/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects progress as a percentage width on the bar', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<SubtasksPanel taskId={1} subtasks={[subtask()]} subtaskCount={4} subtaskCompleted={1} />,
|
||||
);
|
||||
// 1/4 = 25%.
|
||||
const bar = container.querySelector('[style*="width: 25%"]');
|
||||
expect(bar).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders a card per subtask with status badge and first-line title', () => {
|
||||
renderWithProviders(
|
||||
<SubtasksPanel
|
||||
taskId={1}
|
||||
subtasks={[subtask({ status: 'failed', issueNumber: 9 })]}
|
||||
subtaskCount={1}
|
||||
subtaskCompleted={0}
|
||||
/>,
|
||||
);
|
||||
// Status badge label (Failed).
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
// Title = issue number + first line of instruction.
|
||||
expect(screen.getByText(/#9 Summarize chapter one/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not fetch subtask activity/files until a card is expanded', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<SubtasksPanel taskId={5} subtasks={[subtask()]} subtaskCount={1} subtaskCompleted={1} />,
|
||||
);
|
||||
// Collapsed: no lazy fetches yet.
|
||||
expect(api.fetchSubtaskFiles).not.toHaveBeenCalled();
|
||||
// Expanding the card enables the file query.
|
||||
await user.click(screen.getByText(/#7 Summarize chapter one/));
|
||||
expect(api.fetchSubtaskFiles).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../../../i18n';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { POLLING } from '../../../lib/constants.js';
|
||||
import { SubtaskInfo, SubtaskActivity, SubtaskFiles, fetchSubtaskFiles, subtaskFileRawUrl, fetchSubtaskActivity } from '../../../api';
|
||||
import { SubtaskInfo, SubtaskActivity } from '../../../api';
|
||||
import { statusTone, formatStatusLabel, parseActivityLog, isPreviewable } from '../../../lib/utils';
|
||||
import { ActivityTimeline } from '../../activity/ActivityTimeline';
|
||||
import { LinkifiedText } from '../../../lib/linkified-text';
|
||||
import { OutputPreviewProvider } from '../../../lib/output-preview-context';
|
||||
import { stripOutputPrefix } from '../../../lib/output-path-detect';
|
||||
import { useTaskDataSource, type TaskDataSource } from '../task-data-source';
|
||||
|
||||
export type SubtaskFilePreviewHandler = (taskId: number, jobId: string, category: string, filePath: string) => void;
|
||||
|
||||
@@ -38,7 +39,7 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
|
||||
const CATEGORY_ORDER = ['output', 'logs', 'input'];
|
||||
|
||||
function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: number; jobId: string; category: string; files: string[]; onFilePreview?: SubtaskFilePreviewHandler }) {
|
||||
function FileList({ taskId, jobId, category, files, onFilePreview, dataSource }: { taskId: number; jobId: string; category: string; files: string[]; onFilePreview?: SubtaskFilePreviewHandler; dataSource: TaskDataSource }) {
|
||||
const label = i18n.t('detail:subtasks.category.' + category, { defaultValue: CATEGORY_LABELS[category] ?? category });
|
||||
return (
|
||||
<div className="mt-2">
|
||||
@@ -57,7 +58,7 @@ function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: n
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={subtaskFileRawUrl(taskId, jobId, `${category}/${filePath}`)}
|
||||
href={dataSource.subtaskFileRawUrl(taskId, jobId, `${category}/${filePath}`)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline break-all"
|
||||
@@ -74,14 +75,16 @@ function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: n
|
||||
}
|
||||
|
||||
function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardProps) {
|
||||
const { t } = useTranslation('detail');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const dataSource = useTaskDataSource();
|
||||
const tone = statusTone(subtask.status);
|
||||
const title = subtask.instruction.split('\n')[0]?.slice(0, 100) ?? '';
|
||||
const isActive = ACTIVE_STATUSES.has(subtask.status);
|
||||
|
||||
const { data: activityLog } = useQuery({
|
||||
queryKey: ['subtaskActivity', taskId, subtask.id],
|
||||
queryFn: () => fetchSubtaskActivity(taskId, subtask.id),
|
||||
queryKey: ['subtaskActivity', dataSource.readonly, taskId, subtask.id],
|
||||
queryFn: () => dataSource.fetchSubtaskActivity(taskId, subtask.id),
|
||||
refetchInterval: POLLING.FAST,
|
||||
enabled: expanded && isActive,
|
||||
});
|
||||
@@ -90,8 +93,8 @@ function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardPr
|
||||
const activityEvents = expanded ? parseActivityLog(displayLog) : [];
|
||||
|
||||
const { data: subtaskFiles, isLoading: filesLoading } = useQuery({
|
||||
queryKey: ['subtask-files', taskId, subtask.id],
|
||||
queryFn: () => fetchSubtaskFiles(taskId, subtask.id),
|
||||
queryKey: ['subtask-files', dataSource.readonly, taskId, subtask.id],
|
||||
queryFn: () => dataSource.fetchSubtaskFiles(taskId, subtask.id),
|
||||
enabled: expanded,
|
||||
refetchInterval: isActive ? POLLING.MEDIUM : false,
|
||||
});
|
||||
@@ -166,7 +169,7 @@ function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardPr
|
||||
<div className="text-2xs font-semibold text-slate-500 mb-1">{t('subtasks.files')}</div>
|
||||
{CATEGORY_ORDER.map(cat =>
|
||||
categories[cat] && categories[cat].length > 0 ? (
|
||||
<FileList key={cat} taskId={taskId} jobId={subtask.id} category={cat} files={categories[cat]} onFilePreview={onFilePreview} />
|
||||
<FileList key={cat} taskId={taskId} jobId={subtask.id} category={cat} files={categories[cat]} onFilePreview={onFilePreview} dataSource={dataSource} />
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { LocalTaskComment } from '../../../api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MarkdownText } from '../../../lib/markdown-text';
|
||||
|
||||
// Comment kinds rendered here:
|
||||
// `request` / `comment` (user), `progress` / `result` / `ask` (agent),
|
||||
// `handoff` (system marker for /continue) → rendered as a horizontal
|
||||
// divider instead of a card.
|
||||
export function TimelineTab({ comments }: { comments: LocalTaskComment[] }) {
|
||||
const { t } = useTranslation('detail');
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{comments.map(c => {
|
||||
if (c.kind === 'handoff') {
|
||||
return (
|
||||
<div key={c.id} className="flex items-center gap-2 my-2">
|
||||
<div className="flex-1 border-t border-slate-300" />
|
||||
<div className="text-2xs text-slate-500 font-medium px-2 whitespace-nowrap">{c.body}</div>
|
||||
<div className="flex-1 border-t border-slate-300" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={c.id} className="bg-canvas border border-slate-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<div className="text-xs font-bold text-slate-700">{c.author}</div>
|
||||
<div className="text-2xs text-slate-400">{new Date(c.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="text-2xs text-slate-400 mb-1.5">{c.kind}</div>
|
||||
<MarkdownText text={c.body} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{comments.length === 0 && <div className="text-[13px] text-slate-500">{t('timeline.empty')}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchLocalFileContent } from '../../../api';
|
||||
import { summarizeTraceEvent } from '../../../lib/traceEvent';
|
||||
|
||||
// Mirror of `src/progress/event-log.ts` EventBase. Kept as a duplicate
|
||||
// here because the Vite UI build is a separate project from the engine.
|
||||
@@ -106,89 +107,7 @@ function categoryFor(kind: string): string {
|
||||
}
|
||||
|
||||
function summarizePayload(event: TraceEvent): string {
|
||||
const p = event.payload as Record<string, unknown> | null;
|
||||
if (!p) return '';
|
||||
switch (event.kind) {
|
||||
case 'tool_call': {
|
||||
const args = p.args as Record<string, unknown> | undefined;
|
||||
const filePath = args?.['file_path'] ?? args?.['path'] ?? args?.['url'] ?? args?.['pattern'];
|
||||
return `${String(p.tool ?? '?')}${filePath ? ` ${filePath}` : ''}`;
|
||||
}
|
||||
case 'tool_result':
|
||||
return `${String(p.tool ?? '?')} ${p.isError ? '⚠ error' : 'ok'}${p.cacheHit ? ' (cached)' : ''} ${formatDurationLabel(Number(p.durationMs ?? 0))}`;
|
||||
case 'llm_call_start':
|
||||
return `iter=${p.iteration ?? '?'} msgs=${p.messageCount ?? '?'}`;
|
||||
case 'llm_call_end': {
|
||||
const tokens = (typeof p.promptTokens === 'number' && typeof p.completionTokens === 'number')
|
||||
? ` in=${p.promptTokens} out=${p.completionTokens}`
|
||||
: '';
|
||||
const shape = (p.toolCalls as number) > 0 ? ` tools=${p.toolCalls}`
|
||||
: (p.textChars as number) > 0 ? ` text=${p.textChars}c`
|
||||
: '';
|
||||
return `${formatDurationLabel(Number(p.durationMs ?? 0))}${tokens}${shape}${p.hadError ? ' ⚠' : ''}`;
|
||||
}
|
||||
case 'cache_set':
|
||||
return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`;
|
||||
case 'cache_hit':
|
||||
return `${String(p.tool ?? '?')} from ${String(p.sourceMovement ?? '?')} (${p.ageMs ?? '?'}ms ago)`;
|
||||
case 'cache_invalidate':
|
||||
case 'memory_invalidate':
|
||||
return `${String(p.trigger ?? '')} → ${p.entriesEvicted ?? 0} entries`;
|
||||
case 'memory_update_call': {
|
||||
const counts = p.counts as Record<string, number> | null;
|
||||
if (!counts) return p.empty ? 'empty payload' : '';
|
||||
const parts: string[] = [];
|
||||
if (counts.factsAdded) parts.push(`facts +${counts.factsAdded}`);
|
||||
if (counts.factsMerged) parts.push(`facts merged ${counts.factsMerged}`);
|
||||
if (counts.decisionsAdded) parts.push(`decisions +${counts.decisionsAdded}`);
|
||||
if (counts.openQuestionsAdded) parts.push(`open_questions +${counts.openQuestionsAdded}`);
|
||||
if (counts.doNotRepeatAdded) parts.push(`do_not_repeat +${counts.doNotRepeatAdded}`);
|
||||
return parts.join(', ') || 'no changes';
|
||||
}
|
||||
case 'memory_handoff_write':
|
||||
return p.skipped ? `skipped: ${p.reason}` : `→ child #${p.subtaskIndex ?? '?'} (${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`;
|
||||
case 'memory_handoff_read':
|
||||
return `from parent ${String(p.parentJobId ?? '?')}`;
|
||||
case 'memory_delta_write':
|
||||
return p.skipped ? `skipped: ${p.reason}` : `${p.childStatus} ${p.partial ? '(partial) ' : ''}(${p.factsCount ?? 0}f / ${p.decisionsCount ?? 0}d)`;
|
||||
case 'memory_delta_absorb':
|
||||
return `${String(p.outcome ?? '?')}${p.childJobId ? ` ← ${p.childJobId}` : ''}`;
|
||||
case 'memory_snapshot_written': {
|
||||
const parts: string[] = [];
|
||||
if (typeof p.facts === 'number') parts.push(`${p.facts}f`);
|
||||
if (typeof p.decisions === 'number') parts.push(`${p.decisions}d`);
|
||||
if (typeof p.openQuestions === 'number') parts.push(`${p.openQuestions}q`);
|
||||
const counts = parts.length ? ` (${parts.join('/')})` : '';
|
||||
const sizeKb = typeof p.bytes === 'number' ? ` ${(p.bytes / 1024).toFixed(1)}KB` : '';
|
||||
return `${String(p.status ?? '?')} → ${String(p.path ?? '?')}${counts}${sizeKb}`;
|
||||
}
|
||||
case 'memory_snapshot_failed':
|
||||
return `${String(p.status ?? '?')} write failed: ${String(p.error ?? '?')}`;
|
||||
case 'watchdog_fire':
|
||||
return `${String(p.kind2 ?? '')} at iter=${p.iteration ?? '?'}`;
|
||||
case 'followup_detected':
|
||||
return `movement=${String(p.movementName ?? '?')}`;
|
||||
case 'context_action':
|
||||
return `${String(p.type ?? '?')} ratio=${typeof p.ratio === 'number' ? (p.ratio * 100).toFixed(0) + '%' : '?'}`;
|
||||
case 'transition':
|
||||
return `→ ${String(p.nextStep ?? '?')}`;
|
||||
case 'complete':
|
||||
return `${String(p.status ?? '?')}`;
|
||||
case 'movement_start':
|
||||
return `visit ${p.visitCount ?? '?'}/${p.maxVisits ?? '?'}`;
|
||||
case 'movement_complete':
|
||||
return `→ ${String(p.next ?? '?')}`;
|
||||
case 'run_start':
|
||||
return `piece=${String(p.pieceName ?? '?')}`;
|
||||
case 'run_complete': {
|
||||
const cancel = p.cancel as { phase?: string; movement?: string } | undefined;
|
||||
const cancelInfo = cancel?.phase ? ` cancel:${cancel.phase}@${cancel.movement ?? '?'}` : '';
|
||||
const snapshot = p.memorySnapshotPath ? ` snapshot:${String(p.memorySnapshotPath).replace(/^logs\//, '')}` : '';
|
||||
return `${String(p.status ?? '?')}${p.abortReason ? ` (${p.abortReason})` : ''}${cancelInfo}${snapshot}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
return summarizeTraceEvent(event);
|
||||
}
|
||||
|
||||
interface TraceTabProps {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createAuthedTaskDataSource,
|
||||
createSharedTaskDataSource,
|
||||
} from './task-data-source';
|
||||
|
||||
// 純ロジックのみ検証(DOM/hook 非依存)。
|
||||
// - authed 実装は readonly=false で /api/local/... を選ぶ
|
||||
// - shared 実装は readonly=true で /api/shared/:token/... を選ぶ
|
||||
// - Provider 未設置時に useTaskDataSource() が authed 既定へ落ちることは
|
||||
// default 値(createAuthedTaskDataSource)で担保する(本体後方互換)。
|
||||
describe('TaskDataSource', () => {
|
||||
describe('authed implementation', () => {
|
||||
const ds = createAuthedTaskDataSource();
|
||||
|
||||
it('is not read-only', () => {
|
||||
expect(ds.readonly).toBe(false);
|
||||
});
|
||||
|
||||
it('builds local subtask raw file URLs', () => {
|
||||
expect(ds.subtaskFileRawUrl(7, 'job-1', 'output/a.png')).toBe(
|
||||
'/api/local/tasks/7/subtasks/job-1/files/output/a.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared implementation', () => {
|
||||
const ds = createSharedTaskDataSource('tok123');
|
||||
|
||||
it('is read-only', () => {
|
||||
expect(ds.readonly).toBe(true);
|
||||
});
|
||||
|
||||
it('builds shared subtask raw file URLs scoped to the token', () => {
|
||||
expect(ds.subtaskFileRawUrl(7, 'job-1', 'output/a.png')).toBe(
|
||||
'/api/shared/tok123/subtasks/job-1/files/output/a.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('encodes the token in the raw URL', () => {
|
||||
const scoped = createSharedTaskDataSource('a/b');
|
||||
expect(scoped.subtaskFileRawUrl(1, 'j', 'x.txt')).toBe(
|
||||
'/api/shared/a%2Fb/subtasks/j/files/x.txt',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
import {
|
||||
fetchSubtaskActivity,
|
||||
fetchSubtaskFiles,
|
||||
subtaskFileRawUrl,
|
||||
fetchSharedSubtaskActivity,
|
||||
fetchSharedSubtaskFiles,
|
||||
sharedSubtaskFileRawUrl,
|
||||
type SubtaskFiles,
|
||||
} from '../../api';
|
||||
|
||||
/**
|
||||
* TaskDataSource — タスク詳細パネルが必要とするデータ取得を抽象化する。
|
||||
*
|
||||
* 本体(認証版)は `/api/local/...` を叩き readonly=false。共有ページは
|
||||
* トークンベースの `/api/shared/:token/...` を叩き readonly=true。
|
||||
* これにより `LocalDetailPanel` / `SubtasksPanel` を共有ページで read-only
|
||||
* 再利用でき、独自実装のドリフトを防ぐ。
|
||||
*
|
||||
* 重要(後方互換): Provider 未設置時、useTaskDataSource() は authed 既定を
|
||||
* 返す。本体は Provider を新たに必須化しない。
|
||||
*/
|
||||
export interface TaskDataSource {
|
||||
/** 編集系 UI を隠すかどうか。authed=false / shared=true。 */
|
||||
readonly: boolean;
|
||||
/** 個別サブタスクの activity.log(全文)を取得する。 */
|
||||
fetchSubtaskActivity(taskId: number, jobId: string): Promise<string>;
|
||||
/** 個別サブタスクのファイル一覧(output/logs/input)を取得する。 */
|
||||
fetchSubtaskFiles(taskId: number, jobId: string): Promise<SubtaskFiles>;
|
||||
/** サブタスクのファイルへの直接 raw URL(`category/relPath` 形式)。 */
|
||||
subtaskFileRawUrl(taskId: number, jobId: string, filePath: string): string;
|
||||
}
|
||||
|
||||
/** 認証版(本体既定)。`/api/local/...` を使い readonly=false。 */
|
||||
export function createAuthedTaskDataSource(): TaskDataSource {
|
||||
return {
|
||||
readonly: false,
|
||||
fetchSubtaskActivity,
|
||||
fetchSubtaskFiles,
|
||||
subtaskFileRawUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/** 共有版(read-only)。`/api/shared/:token/...` を使い readonly=true。 */
|
||||
export function createSharedTaskDataSource(token: string): TaskDataSource {
|
||||
return {
|
||||
readonly: true,
|
||||
fetchSubtaskActivity: (_taskId: number, jobId: string): Promise<string> =>
|
||||
fetchSharedSubtaskActivity(token, jobId),
|
||||
fetchSubtaskFiles: (_taskId: number, jobId: string): Promise<SubtaskFiles> =>
|
||||
fetchSharedSubtaskFiles(token, jobId),
|
||||
subtaskFileRawUrl: (_taskId: number, jobId: string, filePath: string): string =>
|
||||
sharedSubtaskFileRawUrl(token, jobId, filePath),
|
||||
};
|
||||
}
|
||||
|
||||
// Provider 未設置時の既定 = authed。本体(Tasks ページ / Space チャット)は
|
||||
// この既定にそのまま乗るため、Provider を増設しなくても従来挙動を保つ。
|
||||
const TaskDataSourceContext = createContext<TaskDataSource>(createAuthedTaskDataSource());
|
||||
|
||||
export function TaskDataSourceProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: TaskDataSource;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TaskDataSourceContext.Provider value={value}>{children}</TaskDataSourceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTaskDataSource(): TaskDataSource {
|
||||
return useContext(TaskDataSourceContext);
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
* FileBreadcrumb — ファイルブラウザのパンくず(ルート / seg / seg …)。
|
||||
* スペースのファイルタブとタスクのファイルタブで共有する。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DND_FILES_MIME, readDragSources } from '../../lib/fileDnd';
|
||||
|
||||
interface FileBreadcrumbProps {
|
||||
/** 現在パスのセグメント配列(空配列 = ルート)。 */
|
||||
pathSegments: string[];
|
||||
@@ -9,23 +13,53 @@ interface FileBreadcrumbProps {
|
||||
onNavigate: (path: string) => void;
|
||||
/** ルートラベル左に出す任意のプレフィックス(例: '/files')。 */
|
||||
testid?: string;
|
||||
/**
|
||||
* Phase 2: パンくずの祖先セグメント(やルート)へドラッグ&ドロップしたときの移動。
|
||||
* 指定すると祖先がドロップ先になり、上の階層へファイルを移せる。
|
||||
*/
|
||||
onMoveDrop?: (sourcePaths: string[], destDir: string) => void;
|
||||
}
|
||||
|
||||
export function FileBreadcrumb({ pathSegments, onNavigate, testid }: FileBreadcrumbProps) {
|
||||
export function FileBreadcrumb({ pathSegments, onNavigate, testid, onMoveDrop }: FileBreadcrumbProps) {
|
||||
const { t } = useTranslation('files');
|
||||
const [dropPath, setDropPath] = useState<string | null>(null);
|
||||
// ドロップ先になれるのは「現在地ではない」祖先のみ(現在地=最後のセグメント)。
|
||||
const dropProps = (path: string, isCurrent: boolean) =>
|
||||
onMoveDrop && !isCurrent
|
||||
? {
|
||||
onDragOver: (e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes(DND_FILES_MIME)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move' as const;
|
||||
if (dropPath !== path) setDropPath(path);
|
||||
},
|
||||
onDragLeave: () => setDropPath(p => (p === path ? null : p)),
|
||||
onDrop: (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDropPath(null);
|
||||
const sources = readDragSources(e.dataTransfer);
|
||||
if (sources.length) onMoveDrop(sources, path);
|
||||
},
|
||||
'data-drop-target': 'true' as const,
|
||||
}
|
||||
: {};
|
||||
const dropRing = (path: string) =>
|
||||
dropPath === path ? ' ring-2 ring-[var(--brand-primary)] text-slate-900' : '';
|
||||
return (
|
||||
<nav
|
||||
data-testid={testid}
|
||||
aria-label="パンくず"
|
||||
aria-label={t('breadcrumb.nav')}
|
||||
className="flex flex-wrap items-center gap-0.5 text-2xs text-slate-500"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate('')}
|
||||
className="rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700"
|
||||
{...dropProps('', pathSegments.length === 0)}
|
||||
className={`rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700${dropRing('')}`}
|
||||
disabled={pathSegments.length === 0}
|
||||
aria-current={pathSegments.length === 0 ? 'location' : undefined}
|
||||
>
|
||||
ルート
|
||||
{t('breadcrumb.root')}
|
||||
</button>
|
||||
{pathSegments.map((seg, i) => {
|
||||
const prefix = pathSegments.slice(0, i + 1).join('/');
|
||||
@@ -36,7 +70,8 @@ export function FileBreadcrumb({ pathSegments, onNavigate, testid }: FileBreadcr
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(prefix)}
|
||||
className="max-w-[14ch] truncate rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700"
|
||||
{...dropProps(prefix, isLast)}
|
||||
className={`max-w-[14ch] truncate rounded px-1 py-0.5 transition-colors hover:bg-surface hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:text-slate-700${dropRing(prefix)}`}
|
||||
disabled={isLast}
|
||||
aria-current={isLast ? 'location' : undefined}
|
||||
title={seg}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalFileEntry, getLocalFileRawUrl } from '../../api';
|
||||
import { FileBreadcrumb } from './FileBreadcrumb';
|
||||
import { FileTileGrid } from './FileTileGrid';
|
||||
import { FileActions, FileSelectionBar, FileDropzone } from './FileToolbar';
|
||||
import { FileDetailList } from './FileDetailList';
|
||||
import { FileActions, FileSelectionBar, FileDropzone, FileViewToggle, FileSortMenu, type FileSort } from './FileToolbar';
|
||||
import { useFileView } from '../../hooks/useFileView';
|
||||
import { workspaceDirRole } from '../../lib/workspaceDirs';
|
||||
|
||||
/**
|
||||
* タスク詳細のファイルタブで使う書込(アップロード/削除)操作一式。
|
||||
@@ -39,121 +41,20 @@ interface FileBrowserProps {
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
management?: FileManagement;
|
||||
/**
|
||||
* ファイルのダウンロード/生 URL を組み立てる任意の上書き関数。
|
||||
* 指定すると本体既定の `getLocalFileRawUrl(taskId, section, path)` の代わりに使う。
|
||||
* 共有ビュー(`/api/shared/:token/files/raw`)のように taskId/section を持たない
|
||||
* 文脈で raw URL を差し替えるために足した後方互換 prop。未指定なら従来動作。
|
||||
*/
|
||||
rawUrlFor?: (entry: LocalFileEntry) => string;
|
||||
/**
|
||||
* 区分タブ(workspace/input/output/logs)を隠すか。既定 false。
|
||||
* 共有ビューは output のみを配信するため true にして区分タブを出さない。
|
||||
*/
|
||||
hideSections?: boolean;
|
||||
}
|
||||
|
||||
type FileSort = 'name' | 'newest';
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: FileSort; labelKey: string }> = [
|
||||
{ value: 'name', labelKey: 'sort.name' },
|
||||
{ value: 'newest', labelKey: 'sort.newest' },
|
||||
];
|
||||
|
||||
function FileSortMenu({ sort, onChange }: { sort: FileSort; onChange: (s: FileSort) => void }) {
|
||||
const { t } = useTranslation('files');
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const current = SORT_OPTIONS.find(o => o.value === sort) ?? SORT_OPTIONS[0];
|
||||
|
||||
const handleSelect = (value: FileSort) => {
|
||||
onChange(value);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
title={t('sort.tooltip', { mode: t(current.labelKey) })}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={t('sort.tooltip', { mode: t(current.labelKey) })}
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
open ? 'bg-accent-soft text-accent' : 'text-slate-500 hover:text-slate-900 hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.75}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-[calc(100%+6px)] z-10 bg-canvas border border-hairline rounded-md shadow min-w-[140px] p-1">
|
||||
{SORT_OPTIONS.map(o => {
|
||||
const selected = sort === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(o.value)}
|
||||
className={`flex items-center justify-between w-full px-2.5 py-1.5 rounded text-xs text-left transition-colors ${
|
||||
selected
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 font-medium hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{t(o.labelKey)}
|
||||
{selected && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sortEntries(entries: LocalFileEntry[], mode: FileSort): LocalFileEntry[] {
|
||||
const dirs = entries.filter(e => e.kind === 'directory');
|
||||
const files = entries.filter(e => e.kind !== 'directory');
|
||||
const sortFn = mode === 'newest'
|
||||
? (a: LocalFileEntry, b: LocalFileEntry) => {
|
||||
const at = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
|
||||
const bt = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
|
||||
if (at !== bt) return bt - at;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
: (a: LocalFileEntry, b: LocalFileEntry) => a.name.localeCompare(b.name);
|
||||
return [...dirs.sort(sortFn), ...files.sort(sortFn)];
|
||||
}
|
||||
|
||||
export function FileBrowser({
|
||||
section,
|
||||
@@ -167,23 +68,36 @@ export function FileBrowser({
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
management,
|
||||
rawUrlFor,
|
||||
hideSections,
|
||||
}: FileBrowserProps) {
|
||||
const { t } = useTranslation('files');
|
||||
const SECTIONS = ['workspace', 'input', 'output', 'logs'] as const;
|
||||
const [sort, setSort] = useState<FileSort>('name');
|
||||
const sortedEntries = useMemo(() => sortEntries(entries, sort), [entries, sort]);
|
||||
const { viewMode, setViewMode, sort, setSort, toggleSort, sortedEntries } = useFileView(entries);
|
||||
// アイコン表示のドロップダウン(名前順/新しい順)は共有ソート状態の上に薄く乗せる。
|
||||
// サイズ順は詳細表示の列見出しから操作する。
|
||||
const menuSort: FileSort = sort.key === 'modified' ? 'newest' : 'name';
|
||||
const onMenuSort = (s: FileSort) =>
|
||||
setSort(s === 'newest' ? { key: 'modified', dir: 'desc' } : { key: 'name', dir: 'asc' });
|
||||
|
||||
// 書込ツールバーを出す条件: management があり、owner/admin かつ書込可能区分。
|
||||
const canWrite = !!management && management.canManage && management.writableSection;
|
||||
const filePaths = sortedEntries.filter(e => e.kind !== 'directory').map(e => e.path);
|
||||
const selectedInView = management ? filePaths.filter(p => management.selected.has(p)) : [];
|
||||
const allSelected = filePaths.length > 0 && selectedInView.length === filePaths.length;
|
||||
// raw/ダウンロード URL ビルダー。明示の rawUrlFor を最優先し、
|
||||
// 無ければ taskId がある場合に限り本体既定(local raw URL)を使う。
|
||||
const fileHref = rawUrlFor ?? (taskId != null ? ((entry: LocalFileEntry) => getLocalFileRawUrl(taskId, section, entry.path)) : undefined);
|
||||
// 選択・一括削除/DL の対象: ファイル+ユーザー作成フォルダ(構造フォルダ=workspaceDirRole 有
|
||||
// は足場なので除外)。フォルダ単体の zip DL は各行の onDownloadDir で扱う。
|
||||
const selectablePaths = sortedEntries
|
||||
.filter(e => e.kind !== 'directory' || !workspaceDirRole(e.path, e.name, e.kind))
|
||||
.map(e => e.path);
|
||||
const selectedInView = management ? selectablePaths.filter(p => management.selected.has(p)) : [];
|
||||
const allSelected = selectablePaths.length > 0 && selectedInView.length === selectablePaths.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 区分タブ + アクション(追加 / 再読み込み) */}
|
||||
<div className="flex gap-1 flex-wrap items-center">
|
||||
{SECTIONS.map(s => (
|
||||
{!hideSections && SECTIONS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { onSectionChange(s); onNavigate(''); }}
|
||||
@@ -208,21 +122,34 @@ export function FileBrowser({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* パス表示 + 並び替え */}
|
||||
{/* パンくず(現在地)+ 並び替え・表示切替。パスはパンくずのみで表す(テキスト二重表示を廃止)。 */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-2xs text-slate-500 font-mono break-all min-w-0 flex-1 pt-1">
|
||||
/{section}{currentPath ? `/${currentPath}` : ''}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<FileBreadcrumb testid="task-files-breadcrumb" pathSegments={pathSegments} onNavigate={onNavigate} />
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{viewMode === 'icon' && <FileSortMenu sort={menuSort} onChange={onMenuSort} />}
|
||||
<FileViewToggle idPrefix="task" mode={viewMode} onChange={setViewMode} />
|
||||
</div>
|
||||
<FileSortMenu sort={sort} onChange={setSort} />
|
||||
</div>
|
||||
|
||||
<FileBreadcrumb testid="task-files-breadcrumb" pathSegments={pathSegments} onNavigate={onNavigate} />
|
||||
{/* step4「成果物の在りか」の案内。output 区分の最上位がまだ空のときだけ、
|
||||
エージェントの成果物がここに溜まることを控えめに示す(チャット空状態の
|
||||
案内と同じ文言・トーンに合わせる)。ファイルが出来たら自然に消える。 */}
|
||||
{section === 'output' && currentPath === '' && entries.length === 0 && (
|
||||
<p
|
||||
data-testid="output-empty-hint"
|
||||
className="rounded-md border border-hairline bg-surface px-3 py-2 text-2xs text-slate-400 leading-relaxed"
|
||||
>
|
||||
{t('browser.outputHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canWrite && filePaths.length > 0 && (
|
||||
{canWrite && selectablePaths.length > 0 && (
|
||||
<FileSelectionBar
|
||||
idPrefix="task"
|
||||
allSelected={allSelected}
|
||||
onToggleSelectAll={() => management?.toggleSelectAll(filePaths)}
|
||||
onToggleSelectAll={() => management?.toggleSelectAll(selectablePaths)}
|
||||
selectedCount={selectedInView.length}
|
||||
onDeleteSelected={() => management?.remove(selectedInView)}
|
||||
onDownloadSelected={() => management?.download(selectedInView)}
|
||||
@@ -243,21 +170,43 @@ export function FileBrowser({
|
||||
isUploading={management?.isUploading}
|
||||
onDropFiles={files => management?.upload(files)}
|
||||
>
|
||||
<FileTileGrid
|
||||
entries={sortedEntries}
|
||||
idPrefix="task"
|
||||
canManage={canWrite}
|
||||
selected={management?.selected ?? new Set()}
|
||||
onToggleSelect={path => management?.toggleSelect(path)}
|
||||
onOpenDir={onNavigate}
|
||||
onOpenFile={onPreview}
|
||||
onDeleteOne={path => management?.remove([path])}
|
||||
isDeleting={management?.isDeleting}
|
||||
fileHref={taskId != null ? (entry => getLocalFileRawUrl(taskId, section, entry.path)) : undefined}
|
||||
emptyHint={canWrite
|
||||
? 'ファイルがありません。ここにドラッグ&ドロップ、または「+ 追加」で追加できます。'
|
||||
: t('empty')}
|
||||
/>
|
||||
{viewMode === 'detail' ? (
|
||||
<FileDetailList
|
||||
entries={sortedEntries}
|
||||
idPrefix="task"
|
||||
canManage={canWrite}
|
||||
selected={management?.selected ?? new Set()}
|
||||
onToggleSelect={path => management?.toggleSelect(path)}
|
||||
onOpenDir={onNavigate}
|
||||
onOpenFile={onPreview}
|
||||
onDeleteOne={path => management?.remove([path])}
|
||||
isDeleting={management?.isDeleting}
|
||||
fileHref={fileHref}
|
||||
onDownloadDir={management ? (path => management.download([path])) : undefined}
|
||||
sort={sort}
|
||||
onSort={toggleSort}
|
||||
emptyHint={canWrite
|
||||
? t('browser.emptyManage')
|
||||
: t('empty')}
|
||||
/>
|
||||
) : (
|
||||
<FileTileGrid
|
||||
entries={sortedEntries}
|
||||
idPrefix="task"
|
||||
canManage={canWrite}
|
||||
selected={management?.selected ?? new Set()}
|
||||
onToggleSelect={path => management?.toggleSelect(path)}
|
||||
onOpenDir={onNavigate}
|
||||
onOpenFile={onPreview}
|
||||
onDeleteOne={path => management?.remove([path])}
|
||||
isDeleting={management?.isDeleting}
|
||||
fileHref={fileHref}
|
||||
onDownloadDir={management ? (path => management.download([path])) : undefined}
|
||||
emptyHint={canWrite
|
||||
? t('browser.emptyManage')
|
||||
: t('empty')}
|
||||
/>
|
||||
)}
|
||||
</FileDropzone>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* FileDetailList — ファイル/ディレクトリの詳細(テーブル)表示。Windows エクスプローラーの
|
||||
* 「詳細表示」に相当し、名前・更新日時・サイズを一覧で見せる。
|
||||
*
|
||||
* FileTileGrid と同じ操作プロップ(open/select/delete/download)を受ける純表示部品。
|
||||
* 並べ替えは列見出しクリックで行い、状態は呼出側(useFileView)が持つ。
|
||||
*
|
||||
* testid は idPrefix から組み立てる('task' → task-files-list / task-file-row …)。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalFileEntry } from '../../api';
|
||||
import { FileTypeIcon } from './FileTypeIcon';
|
||||
import { formatFileSize, formatFileTimestamp, type FileSortKey, type FileSortState } from '../../lib/fileView';
|
||||
import { workspaceDirRole } from '../../lib/workspaceDirs';
|
||||
import { DND_FILES_MIME, dragSources, readDragSources } from '../../lib/fileDnd';
|
||||
|
||||
interface FileDetailListProps {
|
||||
entries: LocalFileEntry[];
|
||||
idPrefix: string;
|
||||
canManage: boolean;
|
||||
selected: Set<string>;
|
||||
onToggleSelect: (path: string) => void;
|
||||
onOpenDir: (path: string) => void;
|
||||
onOpenFile: (path: string, name: string) => void;
|
||||
onDeleteOne: (path: string) => void;
|
||||
isDeleting?: boolean;
|
||||
fileHref?: (entry: LocalFileEntry) => string;
|
||||
emptyHint?: React.ReactNode;
|
||||
/** 現在の並べ替え状態(見出しの ▲▼ 表示に使う)。 */
|
||||
sort: FileSortState;
|
||||
/** 列見出しクリック。 */
|
||||
onSort: (key: FileSortKey) => void;
|
||||
/**
|
||||
* 行末の操作列に差し込む任意アクション(例: スペースの「アプリとして実行」)。
|
||||
* アイコン表示の renderTileOverlay と対になる詳細表示版。
|
||||
*/
|
||||
renderRowAction?: (entry: LocalFileEntry) => React.ReactNode;
|
||||
/**
|
||||
* Phase 2: ドラッグ移動。指定すると(かつ canManage が true なら)行をドラッグして
|
||||
* フォルダ行へドロップで移動できる。sourcePaths は選択を考慮した移動元集合。
|
||||
*/
|
||||
onMoveDrop?: (sourcePaths: string[], destDir: string) => void;
|
||||
/** フォルダの zip ダウンロード。指定するとフォルダ行に zip DL ボタンを出す(構造フォルダ含む全フォルダ)。 */
|
||||
onDownloadDir?: (path: string) => void;
|
||||
}
|
||||
|
||||
const HEADERS: Array<{ key: FileSortKey; labelKey: string; align: 'left' | 'right'; className: string }> = [
|
||||
{ key: 'name', labelKey: 'detail.header.name', align: 'left', className: '' },
|
||||
{ key: 'modified', labelKey: 'detail.header.modified', align: 'left', className: 'w-36 whitespace-nowrap' },
|
||||
{ key: 'size', labelKey: 'detail.header.size', align: 'right', className: 'w-24 whitespace-nowrap' },
|
||||
];
|
||||
|
||||
function SortArrow({ active, dir }: { active: boolean; dir: 'asc' | 'desc' }) {
|
||||
if (!active) return null;
|
||||
return <span className="ml-1 text-[9px] text-slate-400" aria-hidden>{dir === 'asc' ? '▲' : '▼'}</span>;
|
||||
}
|
||||
|
||||
export function FileDetailList({
|
||||
entries,
|
||||
idPrefix,
|
||||
canManage,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onOpenDir,
|
||||
onOpenFile,
|
||||
onDeleteOne,
|
||||
isDeleting,
|
||||
fileHref,
|
||||
emptyHint,
|
||||
sort,
|
||||
onSort,
|
||||
renderRowAction,
|
||||
onMoveDrop,
|
||||
onDownloadDir,
|
||||
}: FileDetailListProps) {
|
||||
const { t } = useTranslation('files');
|
||||
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
|
||||
const dndEnabled = canManage && !!onMoveDrop;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table data-testid={`${idPrefix}-files-list`} className="w-full min-w-[420px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-hairline text-slate-500">
|
||||
{canManage && <th className="w-7 px-1 py-1.5" />}
|
||||
{HEADERS.map((h) => {
|
||||
const label = t(h.labelKey);
|
||||
return (
|
||||
<th
|
||||
key={h.key}
|
||||
aria-sort={sort.key === h.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
className={`px-2 py-1.5 font-medium ${h.align === 'right' ? 'text-right' : 'text-left'} ${h.className}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-files-sort-${h.key}`}
|
||||
onClick={() => onSort(h.key)}
|
||||
className={`inline-flex items-center hover:text-slate-800 ${h.align === 'right' ? 'flex-row-reverse' : ''}`}
|
||||
title={t('detail.sortBy', { label })}
|
||||
>
|
||||
{label}
|
||||
<SortArrow active={sort.key === h.key} dir={sort.dir} />
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
{/* 行操作(ダウンロード/削除)用の余白列 */}
|
||||
<th className="w-16 px-1 py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const isFile = entry.kind !== 'directory';
|
||||
// 構造フォルダ(input/output/logs/apps/readonly 等 = role 有)は足場なので
|
||||
// 選択・削除・移動の対象外。ダウンロードは全フォルダ可(下の onDownloadDir)。
|
||||
const dirRole = workspaceDirRole(entry.path, entry.name, entry.kind);
|
||||
const canSelectDelete = canManage && (isFile || !dirRole);
|
||||
const isChecked = canSelectDelete && selected.has(entry.path);
|
||||
const isDropTarget = dndEnabled && entry.kind === 'directory';
|
||||
const isDragOver = isDropTarget && dragOverPath === entry.path;
|
||||
const canDrag = dndEnabled && !dirRole;
|
||||
return (
|
||||
<tr
|
||||
key={`${entry.kind}:${entry.path}`}
|
||||
data-testid={`${idPrefix}-file-row`}
|
||||
data-kind={entry.kind}
|
||||
data-name={entry.name}
|
||||
draggable={canDrag || undefined}
|
||||
onDragStart={canDrag ? e => {
|
||||
e.dataTransfer.setData(DND_FILES_MIME, JSON.stringify(dragSources(entry, selected)));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
} : undefined}
|
||||
onDragOver={isDropTarget ? e => {
|
||||
if (!e.dataTransfer.types.includes(DND_FILES_MIME)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragOverPath !== entry.path) setDragOverPath(entry.path);
|
||||
} : undefined}
|
||||
onDragLeave={isDropTarget ? () => setDragOverPath(p => (p === entry.path ? null : p)) : undefined}
|
||||
onDrop={isDropTarget ? e => {
|
||||
e.preventDefault();
|
||||
setDragOverPath(null);
|
||||
const sources = readDragSources(e.dataTransfer);
|
||||
if (sources.length) onMoveDrop!(sources, entry.path);
|
||||
} : undefined}
|
||||
data-drop-target={isDropTarget ? 'true' : undefined}
|
||||
className={`group border-b border-hairline/60 hover:bg-surface ${
|
||||
isDragOver ? 'ring-2 ring-inset ring-[var(--brand-primary)] bg-surface' : isChecked ? 'bg-surface' : ''
|
||||
}`}
|
||||
>
|
||||
{canManage && (
|
||||
<td className="px-1 py-1 align-middle">
|
||||
{canSelectDelete && (
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`${idPrefix}-file-select-${entry.name}`}
|
||||
checked={isChecked}
|
||||
onChange={() => onToggleSelect(entry.path)}
|
||||
aria-label={t('detail.selectAria', { name: entry.name })}
|
||||
className="h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)]"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-2 py-1 align-middle">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-file-tile`}
|
||||
data-kind={entry.kind}
|
||||
data-name={entry.name}
|
||||
onClick={() => (entry.kind === 'directory' ? onOpenDir(entry.path) : onOpenFile(entry.path, entry.name))}
|
||||
title={entry.name}
|
||||
className="flex min-w-0 items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 rounded"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-slate-400" aria-hidden>
|
||||
{entry.kind === 'directory' ? (
|
||||
<svg className="h-5 w-5 text-amber-400" viewBox="0 0 16 16" fill="currentColor" stroke="none">
|
||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
|
||||
</svg>
|
||||
) : (
|
||||
<FileTypeIcon name={entry.name} className="h-5 w-5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-slate-700">{entry.name}</span>
|
||||
{(() => {
|
||||
const role = workspaceDirRole(entry.path, entry.name, entry.kind);
|
||||
if (!role) return null;
|
||||
return (
|
||||
<span
|
||||
data-testid={`${idPrefix}-dir-badge-${entry.name}`}
|
||||
data-writable={role.writable ? 'true' : 'false'}
|
||||
title={role.title}
|
||||
className={`shrink-0 inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium ${role.className}`}
|
||||
>
|
||||
{!role.writable && (
|
||||
<svg className="h-2.5 w-2.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
|
||||
<rect x="3.5" y="7" width="9" height="6" rx="1" />
|
||||
<path d="M5.5 7V5a2.5 2.5 0 015 0v2" />
|
||||
</svg>
|
||||
)}
|
||||
{role.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-1 align-middle whitespace-nowrap text-slate-500" title={entry.modifiedAt ?? ''}>
|
||||
{formatFileTimestamp(entry.modifiedAt)}
|
||||
</td>
|
||||
<td className="px-2 py-1 align-middle whitespace-nowrap text-right tabular-nums text-slate-500">
|
||||
{isFile ? formatFileSize(entry.size) : '—'}
|
||||
</td>
|
||||
<td className="px-1 py-1 align-middle">
|
||||
{(() => {
|
||||
// 行アクション(リネーム等)はファイル・フォルダ両方。ファイルは <a> 直 DL、
|
||||
// フォルダは zip DL(onDownloadDir)。削除はファイル+ユーザー作成フォルダ。
|
||||
const rowAction = renderRowAction?.(entry);
|
||||
const showDirDownload = entry.kind === 'directory' && !!onDownloadDir;
|
||||
const showOps = (isFile && fileHref) || showDirDownload || canSelectDelete;
|
||||
if (!rowAction && !showOps) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{rowAction}
|
||||
{isFile && fileHref && (
|
||||
<a
|
||||
href={fileHref(entry)}
|
||||
download={entry.name}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`${idPrefix}-file-download-${entry.name}`}
|
||||
title={t('detail.download')}
|
||||
aria-label={t('detail.downloadAria', { name: entry.name })}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{showDirDownload && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-dir-download-${entry.name}`}
|
||||
onClick={(e) => { e.stopPropagation(); onDownloadDir!(entry.path); }}
|
||||
title={t('detail.dirDownload')}
|
||||
aria-label={t('detail.dirDownloadAria', { name: entry.name })}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{canSelectDelete && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-file-delete-${entry.name}`}
|
||||
onClick={() => onDeleteOne(entry.path)}
|
||||
disabled={isDeleting}
|
||||
title={entry.kind === 'directory' ? t('detail.deleteDir') : t('detail.delete')}
|
||||
aria-label={t('detail.deleteAria', { name: entry.name })}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover disabled:opacity-50 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{entries.length === 0 && emptyHint && (
|
||||
<div className="px-1 py-6 text-center text-xs text-slate-500">{emptyHint}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component-render tests for FilePreview's per-type body branches:
|
||||
* - markdown → rendered HTML; a RELATIVE image href is rewritten to the
|
||||
* workspace raw-file endpoint via resolvePreviewImageHref
|
||||
* (the pure helper itself is tested in lib/filePreviewPath.test.ts;
|
||||
* here we assert the COMPONENT actually wires it into the <img src>).
|
||||
* - csv → an HTML <table> with one <td> per cell
|
||||
* - jsonl → renders rows (does not crash on structured lines)
|
||||
* - unknown → falls back to a <pre> with the raw content
|
||||
*
|
||||
* Heavy/irrelevant deps are mocked: mermaid (async diagram run), the office API,
|
||||
* and EmbedBlock. i18n uses the real instance for the 'files' namespace.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import '../../i18n';
|
||||
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
// jsdom has no IntersectionObserver; MarkdownPreview's scroll-spy effect uses
|
||||
// one when the doc has headings. Provide a no-op stub so the effect is inert.
|
||||
beforeAll(() => {
|
||||
if (!('IntersectionObserver' in globalThis)) {
|
||||
class IO {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() { return []; }
|
||||
}
|
||||
// @ts-expect-error test stub
|
||||
globalThis.IntersectionObserver = IO;
|
||||
}
|
||||
});
|
||||
|
||||
// mermaid.run is fire-and-forget inside a useEffect; stub it so jsdom does not
|
||||
// choke on diagram rendering.
|
||||
vi.mock('mermaid', () => ({
|
||||
default: { initialize: vi.fn(), run: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
// EmbedBlock fetches structured blocks; the markdown we feed has no embeds, but
|
||||
// stub it anyway to keep the module graph light and network-free.
|
||||
vi.mock('../embed/EmbedBlock', () => ({ EmbedBlock: () => null }));
|
||||
|
||||
// Avoid pulling real network code from the office-preview path.
|
||||
vi.mock('../../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchOfficePreview: vi.fn(),
|
||||
updateLocalFileContent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { FilePreview } from './FilePreview';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe('FilePreview body branches', () => {
|
||||
it('markdown: renders HTML and rewrites a relative image href to the raw endpoint', () => {
|
||||
const base = '/api/local/tasks/42/files/raw?path=';
|
||||
const md = '# Title\n\n\n';
|
||||
render(
|
||||
<FilePreview
|
||||
name="report.md"
|
||||
content={md}
|
||||
markdownImageBaseUrl={base}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
// heading rendered as HTML
|
||||
expect(screen.getByRole('heading', { name: /Title/ })).toBeInTheDocument();
|
||||
// the relative image is rewritten through resolvePreviewImageHref
|
||||
const img = screen.getByAltText('alt text') as HTMLImageElement;
|
||||
expect(img.getAttribute('src')).toBe(`${base}${encodeURIComponent('images/pic.png')}`);
|
||||
});
|
||||
|
||||
it('markdown: leaves an absolute image href untouched', () => {
|
||||
const md = '';
|
||||
render(
|
||||
<FilePreview
|
||||
name="a.md"
|
||||
content={md}
|
||||
markdownImageBaseUrl="/api/local/tasks/1/files/raw?path="
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
const img = screen.getByAltText('remote') as HTMLImageElement;
|
||||
expect(img.getAttribute('src')).toBe('https://example.com/x.png');
|
||||
});
|
||||
|
||||
it('csv: renders a table with one cell per value', () => {
|
||||
const csv = 'name,age\nalice,30\nbob,25';
|
||||
render(<FilePreview name="data.csv" content={csv} onClose={noop} />);
|
||||
const table = screen.getByRole('table');
|
||||
const cells = within(table).getAllByRole('cell');
|
||||
// 3 rows x 2 cols = 6 cells
|
||||
expect(cells).toHaveLength(6);
|
||||
expect(within(table).getByText('alice')).toBeInTheDocument();
|
||||
expect(within(table).getByText('25')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('jsonl: renders structured rows without crashing', () => {
|
||||
const jsonl = [
|
||||
JSON.stringify({ tool: 'WebFetch', outcome: 'ok' }),
|
||||
JSON.stringify({ tool: 'Bash', outcome: 'failed' }),
|
||||
].join('\n');
|
||||
render(<FilePreview name="log.jsonl" content={jsonl} onClose={noop} />);
|
||||
// values from the jsonl appear somewhere in the rendered output
|
||||
expect(screen.getByText(/WebFetch/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Bash/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('unknown type: falls back to a <pre> with the raw content', () => {
|
||||
render(<FilePreview name="notes.xyz" content="just some plain text" onClose={noop} />);
|
||||
expect(screen.getByText('just some plain text')).toBeInTheDocument();
|
||||
expect(screen.getByText('just some plain text').tagName).toBe('PRE');
|
||||
});
|
||||
|
||||
it('renders the filename in the header and wires the close button', () => {
|
||||
render(<FilePreview name="report.md" content="# Hi" onClose={noop} />);
|
||||
expect(screen.getByTitle('report.md')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,11 @@ import { Marked, Renderer } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import mermaid from 'mermaid';
|
||||
import hljs from 'highlight.js';
|
||||
import { updateLocalFileContent } from '../../api';
|
||||
import { updateLocalFileContent, fetchOfficePreview, OfficePreviewError } from '../../api';
|
||||
import type { OfficePreview, OfficeSpreadsheetPreview, OfficePresentationPreview } from '../../api';
|
||||
import { EmbedBlock } from '../embed/EmbedBlock';
|
||||
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
|
||||
import { resolvePreviewImageHref } from '../../lib/filePreviewPath';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
||||
@@ -66,6 +68,153 @@ interface FilePreviewProps {
|
||||
filePath?: string;
|
||||
editable?: boolean;
|
||||
trustedHtmlUrl?: string;
|
||||
/** Excel / PowerPoint プレビュー。設定されると office-preview API を取得して描画する。 */
|
||||
office?: OfficePreviewDescriptor;
|
||||
}
|
||||
|
||||
export interface OfficePreviewDescriptor {
|
||||
kind: 'spreadsheet' | 'presentation';
|
||||
/** office-preview エンドポイントの完全 URL */
|
||||
url: string;
|
||||
/** 失敗時のダウンロード用 raw URL (任意) */
|
||||
downloadUrl?: string;
|
||||
}
|
||||
|
||||
// --- Office (Excel / PowerPoint) ---
|
||||
|
||||
/** 0 始まりの列番号を Excel 風の列ラベル (A, B, ... Z, AA) に変換する。 */
|
||||
function columnLabel(n: number): string {
|
||||
let s = '';
|
||||
let x = n + 1;
|
||||
while (x > 0) {
|
||||
const r = (x - 1) % 26;
|
||||
s = String.fromCharCode(65 + r) + s;
|
||||
x = Math.floor((x - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function OfficeSpreadsheetView({ data }: { data: OfficeSpreadsheetPreview }): JSX.Element {
|
||||
const { t } = useTranslation('files');
|
||||
const [active, setActive] = useState(0);
|
||||
const sheet = data.sheets[active] ?? data.sheets[0];
|
||||
if (!sheet) return <p className="text-sm text-slate-400">{t('preview.noSheets')}</p>;
|
||||
const cols = sheet.rows.reduce((m, r) => Math.max(m, r.length), 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{data.sheets.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 border-b border-hairline pb-2">
|
||||
{data.sheets.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setActive(i)}
|
||||
className={`h-7 rounded-md border px-2.5 text-xs transition-colors ${i === active ? 'border-accent bg-accent text-accent-fg' : 'border-hairline bg-canvas text-slate-700 hover:bg-surface'}`}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto max-h-[74vh]">
|
||||
<table className="border-collapse text-xs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 top-0 z-20 border border-slate-200 bg-slate-100 px-2 py-1" />
|
||||
{Array.from({ length: cols }, (_, c) => (
|
||||
<th key={c} className="sticky top-0 z-10 border border-slate-200 bg-slate-100 px-2 py-1 text-center font-semibold text-slate-500">
|
||||
{columnLabel(c)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sheet.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
<td className="sticky left-0 z-10 select-none border border-slate-200 bg-slate-100 px-2 py-1 text-right text-slate-400">{ri + 1}</td>
|
||||
{Array.from({ length: cols }, (_, ci) => (
|
||||
<td key={ci} className="max-w-[320px] truncate border border-slate-200 bg-canvas px-2 py-1 align-top" title={row[ci] ?? ''}>
|
||||
{row[ci] ?? ''}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{sheet.truncated && (
|
||||
<p className="text-2xs text-slate-400">
|
||||
{t('preview.sheetTruncated', { shown: sheet.rows.length, rows: sheet.rowCount, cols: sheet.colCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OfficePresentationView({ data }: { data: OfficePresentationPreview }): JSX.Element {
|
||||
const { t } = useTranslation('files');
|
||||
if (data.slides.length === 0) return <p className="text-sm text-slate-400">{t('preview.noSlides')}</p>;
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{data.slides.map((s) => (
|
||||
<div key={s.index} className="w-full max-w-3xl">
|
||||
<div className="mb-1 text-2xs text-slate-400">{t('preview.slide', { index: s.index })}</div>
|
||||
<img src={s.dataUrl} alt={t('preview.slideAlt', { index: s.index })} className="w-full rounded-lg border border-hairline shadow-sm" />
|
||||
</div>
|
||||
))}
|
||||
{data.truncated && (
|
||||
<p className="text-2xs text-slate-400">{t('preview.slidesTruncated', { shown: data.slides.length, total: data.slideCount })}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OfficePreviewView({ office }: { office: OfficePreviewDescriptor }): JSX.Element {
|
||||
const { t } = useTranslation('files');
|
||||
const [data, setData] = useState<OfficePreview | null>(null);
|
||||
const [err, setErr] = useState<{ message: string; unavailable: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setData(null);
|
||||
setErr(null);
|
||||
fetchOfficePreview(office.url)
|
||||
.then((d) => { if (alive) setData(d); })
|
||||
.catch((e) => {
|
||||
if (!alive) return;
|
||||
const unavailable = e instanceof OfficePreviewError && e.code === 'converter_unavailable';
|
||||
setErr({ message: e instanceof Error ? e.message : t('preview.loadFailed'), unavailable });
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, [office.url, t]);
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-center">
|
||||
<p className="max-w-md text-sm text-slate-600">
|
||||
{err.unavailable
|
||||
? t('preview.converterUnavailable')
|
||||
: t('preview.previewFailed', { message: err.message })}
|
||||
</p>
|
||||
{office.downloadUrl && (
|
||||
<a
|
||||
href={office.downloadUrl}
|
||||
download
|
||||
className="inline-flex h-8 items-center rounded-md border border-hairline bg-canvas px-3 text-xs text-slate-700 hover:bg-surface"
|
||||
>
|
||||
{t('preview.downloadFile')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!data) {
|
||||
return <div className="flex items-center justify-center py-16 text-sm text-slate-400">{t('preview.generating')}</div>;
|
||||
}
|
||||
return data.kind === 'spreadsheet'
|
||||
? <OfficeSpreadsheetView data={data} />
|
||||
: <OfficePresentationView data={data} />;
|
||||
}
|
||||
|
||||
// --- CSV ---
|
||||
@@ -166,11 +315,7 @@ function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string)
|
||||
}
|
||||
if (imageBaseUrl) {
|
||||
renderer.image = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
|
||||
let resolvedHref = href;
|
||||
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
|
||||
const cleanPath = href.replace(/^\.\//, '');
|
||||
resolvedHref = `${imageBaseUrl}${encodeURIComponent(cleanPath)}`;
|
||||
}
|
||||
const resolvedHref = resolvePreviewImageHref(href, imageBaseUrl);
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
return `<img src="${resolvedHref}" alt="${text}"${titleAttr} style="max-width:100%" />`;
|
||||
};
|
||||
@@ -623,7 +768,7 @@ function renderJsonl(content: string): JSX.Element {
|
||||
}
|
||||
|
||||
// --- FilePreview ---
|
||||
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl }: FilePreviewProps) {
|
||||
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable, trustedHtmlUrl, office }: FilePreviewProps) {
|
||||
const { t } = useTranslation('files');
|
||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||
const [editContent, setEditContent] = useState(content);
|
||||
@@ -667,7 +812,7 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
setCurrentContent(editContent);
|
||||
setMode('view');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
setError(err instanceof Error ? err.message : t('saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -703,6 +848,11 @@ export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onC
|
||||
}
|
||||
|
||||
// view mode
|
||||
// Office (Excel / PowerPoint): office-preview API から変換結果を取得して描画。
|
||||
// imageSrc より先に判定する(office ファイルは raw だとバイナリ表示になるため)。
|
||||
if (office) {
|
||||
return <OfficePreviewView office={office} />;
|
||||
}
|
||||
if (imageSrc) {
|
||||
if (/\.html?$/i.test(name)) {
|
||||
return (
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
* testid は `idPrefix` から組み立てる('space' → space-files-grid / space-file-tile …)。
|
||||
* 既存 e2e の testid を保つため、idPrefix を変えるだけで両者を再現できるようにしている。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalFileEntry } from '../../api';
|
||||
import { FileTypeIcon } from './FileTypeIcon';
|
||||
import { workspaceDirRole } from '../../lib/workspaceDirs';
|
||||
import { DND_FILES_MIME, dragSources, readDragSources } from '../../lib/fileDnd';
|
||||
|
||||
interface FileTileGridProps {
|
||||
/** 表示するエントリ(呼出側でソート済み)。 */
|
||||
@@ -33,6 +37,18 @@ interface FileTileGridProps {
|
||||
fileHref?: (entry: LocalFileEntry) => string;
|
||||
/** タイル下部に差し込む任意のオーバーレイ(例: スペースの「アプリとして実行」)。 */
|
||||
renderTileOverlay?: (entry: LocalFileEntry) => React.ReactNode;
|
||||
/**
|
||||
* Phase 2: 各タイル右上に差し込む任意アクション(リネーム等)。ファイル・フォルダ両方に
|
||||
* 出る。詳細表示の renderRowAction と対をなす。
|
||||
*/
|
||||
renderEntryAction?: (entry: LocalFileEntry) => React.ReactNode;
|
||||
/**
|
||||
* Phase 2: ドラッグ移動。指定すると(かつ canManage が true なら)タイルをドラッグして
|
||||
* フォルダタイルへドロップで移動できる。sourcePaths は選択を考慮した移動元集合。
|
||||
*/
|
||||
onMoveDrop?: (sourcePaths: string[], destDir: string) => void;
|
||||
/** フォルダの zip ダウンロード。指定するとフォルダタイルに zip DL ボタンを出す(構造フォルダ含む全フォルダ)。 */
|
||||
onDownloadDir?: (path: string) => void;
|
||||
/** エントリ 0 件のとき表示するヒント。 */
|
||||
emptyHint?: React.ReactNode;
|
||||
}
|
||||
@@ -49,8 +65,14 @@ export function FileTileGrid({
|
||||
isDeleting,
|
||||
fileHref,
|
||||
renderTileOverlay,
|
||||
renderEntryAction,
|
||||
onMoveDrop,
|
||||
onDownloadDir,
|
||||
emptyHint,
|
||||
}: FileTileGridProps) {
|
||||
const { t } = useTranslation('files');
|
||||
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
|
||||
const dndEnabled = canManage && !!onMoveDrop;
|
||||
return (
|
||||
<div
|
||||
data-testid={`${idPrefix}-files-grid`}
|
||||
@@ -58,13 +80,43 @@ export function FileTileGrid({
|
||||
>
|
||||
{entries.map(entry => {
|
||||
const isFile = entry.kind !== 'directory';
|
||||
const isChecked = isFile && selected.has(entry.path);
|
||||
const showControls = isFile && canManage;
|
||||
// 構造フォルダ(input/output/... = role 有)は足場なので選択・削除・移動の対象外。
|
||||
// ダウンロードは全フォルダ可(下の onDownloadDir)。
|
||||
const dirRole = workspaceDirRole(entry.path, entry.name, entry.kind);
|
||||
const canSelectDelete = canManage && (isFile || !dirRole);
|
||||
const isChecked = canSelectDelete && selected.has(entry.path);
|
||||
const isDropTarget = dndEnabled && entry.kind === 'directory';
|
||||
const isDragOver = isDropTarget && dragOverPath === entry.path;
|
||||
const entryAction = renderEntryAction?.(entry);
|
||||
const canDrag = dndEnabled && !dirRole;
|
||||
return (
|
||||
<div
|
||||
key={`${entry.kind}:${entry.path}`}
|
||||
draggable={canDrag || undefined}
|
||||
onDragStart={canDrag ? e => {
|
||||
e.dataTransfer.setData(DND_FILES_MIME, JSON.stringify(dragSources(entry, selected)));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
} : undefined}
|
||||
onDragOver={isDropTarget ? e => {
|
||||
if (!e.dataTransfer.types.includes(DND_FILES_MIME)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (dragOverPath !== entry.path) setDragOverPath(entry.path);
|
||||
} : undefined}
|
||||
onDragLeave={isDropTarget ? () => setDragOverPath(p => (p === entry.path ? null : p)) : undefined}
|
||||
onDrop={isDropTarget ? e => {
|
||||
e.preventDefault();
|
||||
setDragOverPath(null);
|
||||
const sources = readDragSources(e.dataTransfer);
|
||||
if (sources.length) onMoveDrop!(sources, entry.path);
|
||||
} : undefined}
|
||||
data-drop-target={isDropTarget ? 'true' : undefined}
|
||||
className={`group relative rounded-lg border transition-colors ${
|
||||
isChecked ? 'border-[var(--brand-primary)] bg-surface' : 'border-transparent hover:border-hairline hover:bg-surface'
|
||||
isDragOver
|
||||
? 'border-[var(--brand-primary)] ring-2 ring-[var(--brand-primary)] bg-surface'
|
||||
: isChecked
|
||||
? 'border-[var(--brand-primary)] bg-surface'
|
||||
: 'border-transparent hover:border-hairline hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
@@ -95,48 +147,83 @@ export function FileTileGrid({
|
||||
>
|
||||
{entry.name}
|
||||
</span>
|
||||
{(() => {
|
||||
const role = workspaceDirRole(entry.path, entry.name, entry.kind);
|
||||
if (!role) return null;
|
||||
return (
|
||||
<span
|
||||
data-testid={`${idPrefix}-dir-badge-${entry.name}`}
|
||||
data-writable={role.writable ? 'true' : 'false'}
|
||||
title={role.title}
|
||||
className={`inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-medium ${role.className}`}
|
||||
>
|
||||
{!role.writable && (
|
||||
<svg className="h-2.5 w-2.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden>
|
||||
<rect x="3.5" y="7" width="9" height="6" rx="1" />
|
||||
<path d="M5.5 7V5a2.5 2.5 0 015 0v2" />
|
||||
</svg>
|
||||
)}
|
||||
{role.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
{renderTileOverlay?.(entry)}
|
||||
{showControls && (
|
||||
{canSelectDelete && (
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`${idPrefix}-file-select-${entry.name}`}
|
||||
checked={isChecked}
|
||||
onChange={() => onToggleSelect(entry.path)}
|
||||
title="選択"
|
||||
aria-label={`${entry.name} を選択`}
|
||||
className={`absolute left-1.5 top-1.5 h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)] transition-opacity ${
|
||||
title={t('tile.select')}
|
||||
aria-label={t('tile.selectAria', { name: entry.name })}
|
||||
className={`absolute left-1.5 top-1.5 h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)] transition-opacity reveal-hover ${
|
||||
isChecked ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
{/* 右上クラスタ: ダウンロード(全員)+ 削除(編集権あり時)。ファイルのみ。 */}
|
||||
{isFile && (fileHref || showControls) && (
|
||||
{/* 右上クラスタ: リネーム等(ファイル/フォルダ)+ ダウンロード(ファイル=直/フォルダ=zip)+ 削除。 */}
|
||||
{(entryAction || (isFile && fileHref) || (entry.kind === 'directory' && onDownloadDir) || canSelectDelete) && (
|
||||
<div className="absolute right-1 top-1 flex items-center gap-0.5">
|
||||
{fileHref && (
|
||||
{entryAction}
|
||||
{isFile && fileHref && (
|
||||
<a
|
||||
href={fileHref(entry)}
|
||||
download={entry.name}
|
||||
onClick={e => e.stopPropagation()}
|
||||
data-testid={`${idPrefix}-file-download-${entry.name}`}
|
||||
title="ダウンロード"
|
||||
aria-label={`${entry.name} をダウンロード`}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
title={t('tile.download')}
|
||||
aria-label={t('tile.downloadAria', { name: entry.name })}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{showControls && (
|
||||
{entry.kind === 'directory' && onDownloadDir && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-dir-download-${entry.name}`}
|
||||
onClick={e => { e.stopPropagation(); onDownloadDir(entry.path); }}
|
||||
title={t('tile.dirDownload')}
|
||||
aria-label={t('tile.dirDownloadAria', { name: entry.name })}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{canSelectDelete && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-file-delete-${entry.name}`}
|
||||
onClick={() => onDeleteOne(entry.path)}
|
||||
disabled={isDeleting}
|
||||
title="削除"
|
||||
aria-label={`${entry.name} を削除`}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-700 focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-50 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
title={entry.kind === 'directory' ? t('tile.deleteDir') : t('tile.delete')}
|
||||
aria-label={t('tile.deleteAria', { name: entry.name })}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-red-50 hover:text-red-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover disabled:opacity-50 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
|
||||
|
||||
@@ -5,10 +5,103 @@
|
||||
* - FileActions … 「追加」ボタン + 隠し input + 再読み込み
|
||||
* - FileSelectionBar … すべて選択 + 選択件数 + 削除(複数選択)
|
||||
* - FileDropzone … 子をラップし、ドラッグ&ドロップでアップロードを受ける
|
||||
* - FileViewToggle … アイコン表示 / 詳細表示 の切替(セグメント)
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FileViewMode } from '../../lib/fileView';
|
||||
|
||||
export { filesToBase64 } from '../../lib/fileBase64';
|
||||
/**
|
||||
* アイコン表示時の並べ替えメニュー(名前順 / 新しい順)。タスク窓・ワークスペース窓で共有。
|
||||
* 共有ソート状態(FileSortState)を持つ呼出側が、name/modified に縮約してこの 2 値で操作する。
|
||||
*/
|
||||
export type FileSort = 'name' | 'newest';
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: FileSort; labelKey: string }> = [
|
||||
{ value: 'name', labelKey: 'sort.name' },
|
||||
{ value: 'newest', labelKey: 'sort.newest' },
|
||||
];
|
||||
|
||||
export function FileSortMenu({ sort, onChange }: { sort: FileSort; onChange: (s: FileSort) => void }) {
|
||||
const { t } = useTranslation('files');
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const current = SORT_OPTIONS.find(o => o.value === sort) ?? SORT_OPTIONS[0];
|
||||
|
||||
const handleSelect = (value: FileSort) => {
|
||||
onChange(value);
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
title={t('sort.tooltip', { mode: t(current.labelKey) })}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={t('sort.tooltip', { mode: t(current.labelKey) })}
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||
open ? 'bg-accent-soft text-accent' : 'text-slate-500 hover:text-slate-900 hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h13M3 12h9M3 18h5M17 8V4m0 0l-3 3m3-3l3 3" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-[calc(100%+6px)] z-10 bg-canvas border border-hairline rounded-md shadow min-w-[140px] p-1">
|
||||
{SORT_OPTIONS.map(o => {
|
||||
const selected = sort === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => handleSelect(o.value)}
|
||||
className={`flex items-center justify-between w-full px-2.5 py-1.5 rounded text-xs text-left transition-colors ${
|
||||
selected ? 'bg-accent-soft text-accent font-semibold' : 'text-slate-700 font-medium hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{t(o.labelKey)}
|
||||
{selected && (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ICON_BTN =
|
||||
'w-8 h-8 flex items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 hover:text-slate-900 hover:bg-surface transition-colors';
|
||||
@@ -28,6 +121,7 @@ export function FileActions({
|
||||
isRefreshing?: boolean;
|
||||
isUploading?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('files');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
@@ -55,7 +149,7 @@ export function FileActions({
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 3.5v9M3.5 8h9" />
|
||||
</svg>
|
||||
追加
|
||||
{t('toolbar.add')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -64,8 +158,8 @@ export function FileActions({
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
className={`${ICON_BTN} disabled:opacity-50`}
|
||||
title="再読み込み"
|
||||
aria-label="再読み込み"
|
||||
title={t('toolbar.refresh')}
|
||||
aria-label={t('toolbar.refresh')}
|
||||
>
|
||||
<svg className={`h-3.5 w-3.5 ${isRefreshing ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 8a6 6 0 0110.5-4M14 8a6 6 0 01-10.5 4" />
|
||||
@@ -83,8 +177,10 @@ export function FileSelectionBar({
|
||||
selectedCount,
|
||||
onDeleteSelected,
|
||||
onDownloadSelected,
|
||||
onMoveSelected,
|
||||
isDeleting,
|
||||
isDownloading,
|
||||
isMoving,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
allSelected: boolean;
|
||||
@@ -92,9 +188,13 @@ export function FileSelectionBar({
|
||||
selectedCount: number;
|
||||
onDeleteSelected: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
/** Phase 2: 指定すると「移動」ボタンを出す(移動先ダイアログを開く)。 */
|
||||
onMoveSelected?: () => void;
|
||||
isDeleting?: boolean;
|
||||
isDownloading?: boolean;
|
||||
isMoving?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('files');
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-2xs text-slate-500">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 select-none">
|
||||
@@ -105,13 +205,28 @@ export function FileSelectionBar({
|
||||
onChange={onToggleSelectAll}
|
||||
className="h-3.5 w-3.5 rounded border-hairline accent-[var(--brand-primary)]"
|
||||
/>
|
||||
すべて選択
|
||||
{t('toolbar.selectAll')}
|
||||
</label>
|
||||
<span data-testid={`${idPrefix}-files-selected-count`} className="text-slate-400">
|
||||
{selectedCount} 件選択中
|
||||
{t('toolbar.selectedCount', { count: selectedCount })}
|
||||
</span>
|
||||
{selectedCount > 0 && (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{onMoveSelected && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-files-move-selected`}
|
||||
onClick={onMoveSelected}
|
||||
disabled={isMoving}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md border border-hairline bg-canvas px-2.5 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h2.6l1.2 1.6h5.2A1.5 1.5 0 0116 6.1V12a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 012 12V4.5z" />
|
||||
<path d="M9 8.5h3M10.5 7l1.5 1.5L10.5 10" />
|
||||
</svg>
|
||||
{t('toolbar.move')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-files-download-selected`}
|
||||
@@ -122,7 +237,7 @@ export function FileSelectionBar({
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 2v8M4.5 6.5L8 10l3.5-3.5M2.5 13h11" />
|
||||
</svg>
|
||||
ダウンロード
|
||||
{t('toolbar.download')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -134,7 +249,7 @@ export function FileSelectionBar({
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
|
||||
</svg>
|
||||
削除
|
||||
{t('toolbar.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -159,6 +274,7 @@ export function FileDropzone({
|
||||
onRejectFolder?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation('files');
|
||||
const [dragDepth, setDragDepth] = useState(0);
|
||||
|
||||
// ウィンドウ外への drag 離脱や drop 取りこぼしで dragDepth が戻らずオーバーレイが
|
||||
@@ -196,15 +312,53 @@ export function FileDropzone({
|
||||
>
|
||||
{enabled && dragDepth > 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-lg border-2 border-dashed border-slate-400 bg-canvas/85 text-sm font-medium text-slate-600">
|
||||
ここにドロップして追加
|
||||
{t('toolbar.dropToAdd')}
|
||||
</div>
|
||||
)}
|
||||
{enabled && isUploading && (
|
||||
<div className="pointer-events-none absolute right-1 top-1 z-10 rounded bg-slate-800/80 px-2 py-0.5 text-2xs text-white">
|
||||
アップロード中…
|
||||
{t('toolbar.uploading')}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* アイコン表示 / 詳細表示 の切替セグメント。状態は呼出側(useFileView)が持つ。
|
||||
*/
|
||||
export function FileViewToggle({
|
||||
idPrefix,
|
||||
mode,
|
||||
onChange,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
mode: FileViewMode;
|
||||
onChange: (mode: FileViewMode) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('files');
|
||||
const btn = (m: FileViewMode, label: string, path: JSX.Element) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${idPrefix}-files-view-${m}`}
|
||||
onClick={() => onChange(m)}
|
||||
aria-pressed={mode === m}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`flex h-8 w-8 items-center justify-center transition-colors ${
|
||||
mode === m ? 'bg-surface text-slate-900' : 'bg-canvas text-slate-500 hover:bg-surface hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{path}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<div className="flex shrink-0 overflow-hidden rounded-md border border-hairline" role="group" aria-label={t('toolbar.viewToggle')}>
|
||||
{btn('icon', t('toolbar.iconView'), <><rect x="2" y="2" width="5" height="5" rx="1" /><rect x="9" y="2" width="5" height="5" rx="1" /><rect x="2" y="9" width="5" height="5" rx="1" /><rect x="9" y="9" width="5" height="5" rx="1" /></>)}
|
||||
{btn('detail', t('toolbar.detailView'), <><path d="M5.5 4h8M5.5 8h8M5.5 12h8" /><path d="M2.5 4h.01M2.5 8h.01M2.5 12h.01" /></>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* MoveTargetDialog — 複数選択した項目の移動先フォルダを選ぶ軽量ダイアログ(Box 化 Phase 2)。
|
||||
* ドラッグ移動のキーボード/タッチ代替。フォルダだけを辿り、「ここに移動」で確定する。
|
||||
*
|
||||
* 実際の移動(move API 連打)と結果表示は呼出側(SpaceFiles)が onConfirm で行う。
|
||||
* ここは移動先 dir を決めることに専念し、no-op になる移動先では確定ボタンを無効化する。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { fetchSpaceFiles, type LocalFileEntry } from '../../api';
|
||||
import { resolveMoves } from '../../lib/fileMove';
|
||||
import { FileBreadcrumb } from './FileBreadcrumb';
|
||||
|
||||
interface MoveTargetDialogProps {
|
||||
spaceId: string;
|
||||
/** 移動する項目の相対パス集合。 */
|
||||
sourcePaths: string[];
|
||||
onClose: () => void;
|
||||
onConfirm: (destDir: string) => void;
|
||||
isMoving?: boolean;
|
||||
}
|
||||
|
||||
export function MoveTargetDialog({ spaceId, sourcePaths, onClose, onConfirm, isMoving }: MoveTargetDialogProps) {
|
||||
const { t } = useTranslation('files');
|
||||
const [dir, setDir] = useState('');
|
||||
const [folders, setFolders] = useState<LocalFileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async (target: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const r = await fetchSpaceFiles(spaceId, target);
|
||||
setFolders(r.entries.filter(e => e.kind === 'directory'));
|
||||
} catch {
|
||||
setFolders([]);
|
||||
setError(t('move.loadFoldersError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [spaceId, t]);
|
||||
|
||||
useEffect(() => { void load(dir); }, [dir, load]);
|
||||
|
||||
const { moves, skipped } = resolveMoves(sourcePaths, dir);
|
||||
const segments = dir ? dir.split('/').filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
data-testid="move-target-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('move.title', { count: sourcePaths.length })}
|
||||
className="w-full max-w-md rounded-lg border border-hairline bg-canvas shadow-xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-hairline px-4 py-2.5">
|
||||
<h2 className="text-sm font-semibold text-slate-800">{t('move.title', { count: sourcePaths.length })}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('move.close')}
|
||||
className="rounded p-1 text-slate-400 hover:bg-surface hover:text-slate-700"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2">
|
||||
<FileBreadcrumb testid="move-target-breadcrumb" pathSegments={segments} onNavigate={setDir} />
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto rounded border border-hairline">
|
||||
{loading ? (
|
||||
<p className="px-3 py-6 text-center text-xs text-slate-400">{t('move.loading')}</p>
|
||||
) : folders.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-xs text-slate-400">
|
||||
{t('move.noSubfolders')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-hairline/60">
|
||||
{folders.map(f => (
|
||||
<li key={f.path}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`move-target-folder-${f.name}`}
|
||||
onClick={() => setDir(f.path)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-slate-700 hover:bg-surface"
|
||||
>
|
||||
<svg className="h-4 w-4 shrink-0 text-amber-400" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h3l1.5 2h4.5A1.5 1.5 0 0114 6.5v5A1.5 1.5 0 0112.5 13h-9A1.5 1.5 0 012 11.5v-7z" />
|
||||
</svg>
|
||||
<span className="truncate">{f.name}</span>
|
||||
<svg className="ml-auto h-3.5 w-3.5 shrink-0 text-slate-300" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-2 text-xs text-red-600">{error}</p>}
|
||||
<p className="mt-2 text-2xs text-slate-500">
|
||||
{t('move.destination')} <span className="font-medium text-slate-700">{dir ? `/${dir}` : t('move.root')}</span>
|
||||
{skipped.length > 0 && <span className="text-slate-400">{t('move.skipped', { count: skipped.length })}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-hairline px-4 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-3 text-2xs font-medium text-slate-600 hover:bg-surface"
|
||||
>
|
||||
{t('move.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="move-target-confirm"
|
||||
onClick={() => onConfirm(dir)}
|
||||
disabled={isMoving || moves.length === 0}
|
||||
className="inline-flex h-7 items-center rounded-md bg-[var(--brand-primary)] px-3 text-2xs font-semibold text-white hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{moves.length > 0 ? t('move.confirmCount', { count: moves.length }) : t('move.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,16 +33,6 @@ const ICON_PROPS = {
|
||||
};
|
||||
|
||||
const NAV_ICONS: Record<PageId, ReactNode> = {
|
||||
tasks: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<line x1="8" y1="6" x2="20" y2="6" />
|
||||
<line x1="8" y1="12" x2="20" y2="12" />
|
||||
<line x1="8" y1="18" x2="20" y2="18" />
|
||||
<circle cx="4" cy="6" r="1.4" />
|
||||
<circle cx="4" cy="12" r="1.4" />
|
||||
<circle cx="4" cy="18" r="1.4" />
|
||||
</svg>
|
||||
),
|
||||
spaces: (
|
||||
<svg {...ICON_PROPS}>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
|
||||
@@ -25,7 +25,6 @@ interface TopBarProps {
|
||||
// labelKey resolves against the `layout` i18n namespace at render time (module
|
||||
// scope can't call hooks). All consumers translate: TopBar, NavDrawer, App.tsx.
|
||||
export const NAV_ITEMS: Array<{ id: PageId; labelKey: string; adminOnly: boolean; requiresAuth: boolean }> = [
|
||||
{ id: 'tasks', labelKey: 'nav.tasks', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'spaces', labelKey: 'nav.spaces', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'calendar', labelKey: 'nav.calendar', adminOnly: false, requiresAuth: false },
|
||||
{ id: 'schedules', labelKey: 'nav.schedules', adminOnly: false, requiresAuth: false },
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for FilterBar — the search box, sort menu, and status filter
|
||||
* tabs that drive the task list. Verifies counts render, callbacks fire on user
|
||||
* interaction (search input, status tab click, sort selection), and aria state
|
||||
* reflects the current selection.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import '../../i18n'; // initialize i18next so useTranslation('list') resolves
|
||||
import { FilterBar } from './FilterBar';
|
||||
|
||||
function baseProps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
selectedStatus: 'all' as const,
|
||||
sortMode: 'updated' as const,
|
||||
searchQuery: '',
|
||||
counts: { running: 2, failed: 1, queued: 5 },
|
||||
totalCount: 8,
|
||||
onStatusChange: vi.fn(),
|
||||
onSortChange: vi.fn(),
|
||||
onSearchChange: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FilterBar', () => {
|
||||
it('renders the total count on the All tab and per-status counts', () => {
|
||||
renderWithProviders(<FilterBar {...baseProps()} />);
|
||||
const allTab = screen.getByRole('tab', { name: /All/ });
|
||||
expect(allTab).toHaveTextContent('8');
|
||||
// Running status tab shows its count from the counts map.
|
||||
const runningTab = screen.getByRole('tab', { name: /Running/ });
|
||||
expect(runningTab).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('marks the selected status tab as aria-selected', () => {
|
||||
renderWithProviders(<FilterBar {...baseProps({ selectedStatus: 'running' })} />);
|
||||
expect(screen.getByRole('tab', { name: /Running/ })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: /All/ })).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
it('calls onSearchChange as the user types in the search box', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = baseProps();
|
||||
renderWithProviders(<FilterBar {...props} />);
|
||||
const input = screen.getByRole('textbox', { name: /Search/i });
|
||||
await user.type(input, 'a');
|
||||
expect(props.onSearchChange).toHaveBeenCalledWith('a');
|
||||
});
|
||||
|
||||
it('calls onStatusChange when a status tab is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = baseProps();
|
||||
renderWithProviders(<FilterBar {...props} />);
|
||||
await user.click(screen.getByRole('tab', { name: /Failed/ }));
|
||||
expect(props.onStatusChange).toHaveBeenCalledWith('failed');
|
||||
});
|
||||
|
||||
it('opens the sort menu and calls onSortChange with the picked mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = baseProps();
|
||||
renderWithProviders(<FilterBar {...props} />);
|
||||
// Sort menu is collapsed initially.
|
||||
const trigger = screen.getByRole('button', { name: /Sort/i });
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(trigger);
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
||||
// Pick "By title".
|
||||
const menu = trigger.closest('div')!;
|
||||
await user.click(within(menu).getByRole('button', { name: 'By title' }));
|
||||
expect(props.onSortChange).toHaveBeenCalledWith('title');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for LocalTaskListItem — a single row in the task list. Verifies
|
||||
* the title/id/body render, the status badge reflects latestJob.status (falling
|
||||
* back to "queued"/Inbox when absent), the subtask progress fraction shows, the
|
||||
* visibility chip varies by visibility, and clicking fires onClick.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import type { LocalTask } from '../../api';
|
||||
import { LocalTaskListItem } from './TaskListItem';
|
||||
|
||||
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
|
||||
return {
|
||||
id: 42,
|
||||
title: 'Build the report',
|
||||
body: 'Generate a quarterly sales report from the uploaded spreadsheet.',
|
||||
pieceName: 'chat',
|
||||
profile: 'default',
|
||||
outputFormat: 'markdown',
|
||||
askPolicy: 'auto',
|
||||
priority: 'normal',
|
||||
state: 'open',
|
||||
workspacePath: null,
|
||||
createdAt: '2026-06-01T00:00:00Z',
|
||||
updatedAt: '2026-06-01T00:00:00Z',
|
||||
latestJob: { id: 'job-1', status: 'running' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('LocalTaskListItem', () => {
|
||||
it('renders the title, id and body for a running task', () => {
|
||||
renderWithProviders(<LocalTaskListItem task={makeTask()} active={false} onClick={() => {}} />);
|
||||
expect(screen.getByText('Build the report')).toBeInTheDocument();
|
||||
expect(screen.getByText('#42')).toBeInTheDocument();
|
||||
expect(screen.getByText(/quarterly sales report/)).toBeInTheDocument();
|
||||
// Status badge reflects latestJob.status.
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the queued/Inbox status when latestJob is missing', () => {
|
||||
renderWithProviders(
|
||||
<LocalTaskListItem task={makeTask({ latestJob: null })} active={false} onClick={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText('Inbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the subtask completed/total fraction when subtasks exist', () => {
|
||||
renderWithProviders(
|
||||
<LocalTaskListItem
|
||||
task={makeTask({ subtaskCount: 4, subtaskCompleted: 1 })}
|
||||
active={false}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('1/4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the private visibility chip', () => {
|
||||
renderWithProviders(
|
||||
<LocalTaskListItem task={makeTask({ visibility: 'private' })} active={false} onClick={() => {}} />,
|
||||
);
|
||||
expect(screen.getByText('private')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the org name as the visibility chip for org visibility', () => {
|
||||
renderWithProviders(
|
||||
<LocalTaskListItem
|
||||
task={makeTask({ visibility: 'org', visibilityScopeOrgName: 'Acme' })}
|
||||
active={false}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Acme')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick when the row is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
renderWithProviders(<LocalTaskListItem task={makeTask()} active={false} onClick={onClick} />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for TaskListPanel — the list container that wires FilterBar +
|
||||
* TaskListItem to the filter/sort libs. Verifies the full task set renders, the
|
||||
* status filter narrows the rendered rows, the search query narrows them, the
|
||||
* empty-state shows when nothing matches, the summary header counts are correct,
|
||||
* and selecting a row fires onSelectTask with the task id.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import '../../i18n';
|
||||
import type { LocalTask } from '../../api';
|
||||
import { TaskListPanel } from './TaskListPanel';
|
||||
|
||||
function makeTask(id: number, title: string, status: string, body = ''): LocalTask {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
pieceName: 'chat',
|
||||
profile: 'default',
|
||||
outputFormat: 'markdown',
|
||||
askPolicy: 'auto',
|
||||
priority: 'normal',
|
||||
state: 'open',
|
||||
workspacePath: null,
|
||||
createdAt: '2026-06-01T00:00:00Z',
|
||||
updatedAt: `2026-06-0${id}T00:00:00Z`,
|
||||
latestJob: { id: `job-${id}`, status },
|
||||
};
|
||||
}
|
||||
|
||||
const TASKS: LocalTask[] = [
|
||||
makeTask(1, 'Alpha report', 'running', 'first task body'),
|
||||
makeTask(2, 'Beta export', 'failed', 'second task body'),
|
||||
makeTask(3, 'Gamma sync', 'running', 'third task body'),
|
||||
];
|
||||
|
||||
function baseProps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
localTasks: TASKS,
|
||||
selectedStatus: 'all' as const,
|
||||
sortMode: 'updated' as const,
|
||||
searchQuery: '',
|
||||
activeTaskId: null,
|
||||
onStatusChange: vi.fn(),
|
||||
onSortChange: vi.fn(),
|
||||
onSearchChange: vi.fn(),
|
||||
onSelectTask: vi.fn(),
|
||||
onOpenCreate: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskListPanel', () => {
|
||||
it('renders every task when status=all and no query', () => {
|
||||
renderWithProviders(<TaskListPanel {...baseProps()} />);
|
||||
expect(screen.getByText('Alpha report')).toBeInTheDocument();
|
||||
expect(screen.getByText('Beta export')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gamma sync')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters the rendered rows by selected status', () => {
|
||||
renderWithProviders(<TaskListPanel {...baseProps({ selectedStatus: 'failed' })} />);
|
||||
expect(screen.getByText('Beta export')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Alpha report')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Gamma sync')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters the rendered rows by search query (title match)', () => {
|
||||
renderWithProviders(<TaskListPanel {...baseProps({ searchQuery: 'gamma' })} />);
|
||||
expect(screen.getByText('Gamma sync')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Alpha report')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Beta export')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty-state when no task matches', () => {
|
||||
renderWithProviders(<TaskListPanel {...baseProps({ searchQuery: 'nonexistent-xyz' })} />);
|
||||
expect(screen.getByText('No threads yet')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Alpha report')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the summary header counts (total + running)', () => {
|
||||
renderWithProviders(<TaskListPanel {...baseProps()} />);
|
||||
// total = 3, running = 2. The summary header carries these.
|
||||
const allTab = screen.getByRole('tab', { name: /All/ });
|
||||
expect(allTab).toHaveTextContent('3');
|
||||
expect(screen.getByRole('tab', { name: /Running/ })).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('fires onSelectTask with the task id when a row is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = baseProps();
|
||||
renderWithProviders(<TaskListPanel {...props} />);
|
||||
await user.click(screen.getByText('Beta export'));
|
||||
expect(props.onSelectTask).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('fires onOpenCreate when the new-request button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = baseProps();
|
||||
renderWithProviders(<TaskListPanel {...props} />);
|
||||
await user.click(screen.getByRole('button', { name: /New request/i }));
|
||||
expect(props.onOpenCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the scope toggle only when scope is enabled', () => {
|
||||
const { rerender } = renderWithProviders(
|
||||
<TaskListPanel {...baseProps({ scopeEnabled: false })} />,
|
||||
);
|
||||
expect(screen.queryByRole('group', { name: /scope/i })).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<TaskListPanel {...baseProps({ scopeEnabled: true, onScopeChange: vi.fn(), currentUserId: 'u1' })} />,
|
||||
);
|
||||
expect(screen.getByRole('group', { name: /scope/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for AuthForm (settings → auth.*).
|
||||
*
|
||||
* Focus: render with realistic config, the primary-provider <select>, the
|
||||
* adminEmails StringArrayEditor, and the sessionMaxAge number serialization
|
||||
* (string → Number, empty → undefined). Network is not used by this form
|
||||
* (pure SectionFormProps presentational component).
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
import { AuthForm } from './AuthForm';
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
const noEnv = {};
|
||||
|
||||
function render(config: any, onChange = vi.fn()) {
|
||||
renderWithProviders(
|
||||
<AuthForm config={config} onChange={onChange} overriddenByEnv={noEnv} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('AuthForm', () => {
|
||||
it('renders provider fields with realistic existing config', () => {
|
||||
render({
|
||||
auth: {
|
||||
primaryProvider: 'gitea',
|
||||
adminEmails: ['[email protected]'],
|
||||
providers: { gitea: { baseUrl: 'https://gitea.example.com', clientId: 'cid' } },
|
||||
},
|
||||
});
|
||||
// primary provider select reflects stored value
|
||||
const select = screen.getByDisplayValue('gitea only') as HTMLSelectElement;
|
||||
expect(select.value).toBe('gitea');
|
||||
// existing admin email chip rendered by StringArrayEditor
|
||||
expect(screen.getByText('[email protected]')).toBeInTheDocument();
|
||||
// gitea base url field carries the stored value
|
||||
expect(screen.getByDisplayValue('https://gitea.example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits auth.primaryProvider when the select changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ auth: {} });
|
||||
const select = screen.getByDisplayValue('(none — all enabled)');
|
||||
await user.selectOptions(select, 'google');
|
||||
expect(onChange).toHaveBeenCalledWith('auth.primaryProvider', 'google');
|
||||
});
|
||||
|
||||
it('serializes sessionMaxAge to a Number', () => {
|
||||
const onChange = render({ auth: {} });
|
||||
const input = screen.getByRole('spinbutton'); // type=number FieldInput
|
||||
fireEvent.change(input, { target: { value: '86400000' } });
|
||||
expect(onChange).toHaveBeenCalledWith('auth.sessionMaxAge', 86400000);
|
||||
});
|
||||
|
||||
it('clears sessionMaxAge to undefined when emptied', () => {
|
||||
const onChange = render({ auth: { sessionMaxAge: 86400000 } });
|
||||
const input = screen.getByDisplayValue('86400000');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith('auth.sessionMaxAge', undefined);
|
||||
});
|
||||
|
||||
it('adds an admin email through the StringArrayEditor', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ auth: { adminEmails: [] } });
|
||||
const textInput = screen.getByPlaceholderText('[email protected]');
|
||||
await user.type(textInput, '[email protected]');
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onChange).toHaveBeenCalledWith('auth.adminEmails', ['[email protected]']);
|
||||
});
|
||||
|
||||
it('toggles the secureCookie checkbox', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ auth: {} });
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
await user.click(checkboxes[0]); // secureCookie is the first checkbox
|
||||
expect(onChange).toHaveBeenCalledWith('auth.secureCookie', true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for BrowserSettingsForm (settings batch A).
|
||||
*
|
||||
* Focus: this form writes to TWO different config roots — `tools.*` (page/action
|
||||
* timeouts) and `browser.*` (channel, sessions). Verifies the correct dotted
|
||||
* path is used for each, number coercion, select changes, and the
|
||||
* executablePath empty -> undefined contract.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { BrowserSettingsForm } from './BrowserSettingsForm';
|
||||
|
||||
function render(config: any = {}) {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<BrowserSettingsForm config={config} onChange={onChange} overriddenByEnv={{}} />,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
function renderStateful(config: any = {}) {
|
||||
const onChange = vi.fn();
|
||||
const utils = renderStatefulForm(BrowserSettingsForm, config, { onChangeSpy: onChange });
|
||||
return { onChange, ...utils };
|
||||
}
|
||||
|
||||
describe('BrowserSettingsForm', () => {
|
||||
it('renders defaults for both tools.* and browser.* fields', () => {
|
||||
render({});
|
||||
// Page timeout default 60000 (tools.*) and channel select default chromium (browser.*).
|
||||
expect(screen.getByDisplayValue('60000')).toBeInTheDocument();
|
||||
const selects = screen.getAllByRole('combobox') as HTMLSelectElement[];
|
||||
expect(selects[0]).toHaveValue('chromium');
|
||||
});
|
||||
|
||||
it('writes the page timeout under the tools.* root as a number', async () => {
|
||||
const { getConfig } = renderStateful({ tools: { browserPageTimeout: 60000 } });
|
||||
const pageTimeout = screen.getByDisplayValue('60000');
|
||||
await userEvent.clear(pageTimeout);
|
||||
await userEvent.type(pageTimeout, '90000');
|
||||
expect(getConfig().tools.browserPageTimeout).toBe(90000);
|
||||
});
|
||||
|
||||
it('writes the browser channel under the browser.* root', async () => {
|
||||
const { onChange } = render({ browser: { channel: 'chromium' } });
|
||||
const channel = screen.getAllByRole('combobox')[0];
|
||||
await userEvent.selectOptions(channel, 'chrome');
|
||||
expect(onChange).toHaveBeenCalledWith('browser.channel', 'chrome');
|
||||
});
|
||||
|
||||
it('serializes executablePath to undefined when emptied', async () => {
|
||||
const { onChange } = render({ browser: { executablePath: '/usr/bin/chrome' } });
|
||||
const exec = screen.getByDisplayValue('/usr/bin/chrome');
|
||||
await userEvent.clear(exec);
|
||||
expect(onChange).toHaveBeenLastCalledWith('browser.executablePath', undefined);
|
||||
});
|
||||
|
||||
it('serializes idle TTL to undefined when emptied, number when set', async () => {
|
||||
const { getConfig } = renderStateful({ browser: { taskSessionIdleTtl: 120 } });
|
||||
const ttl = screen.getByDisplayValue('120');
|
||||
await userEvent.clear(ttl);
|
||||
expect(getConfig().browser.taskSessionIdleTtl).toBeUndefined();
|
||||
await userEvent.type(ttl, '300');
|
||||
expect(getConfig().browser.taskSessionIdleTtl).toBe(300);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ContextForm (settings batch A).
|
||||
*
|
||||
* Focus: the threshold-row serialization. Each row is { ratio, action }; editing
|
||||
* a ratio must coerce to Number and rewrite the WHOLE thresholds array (not just
|
||||
* one field), and editing an action must keep ratio intact. Also covers
|
||||
* limitTokens empty -> undefined.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { ContextForm } from './ContextForm';
|
||||
|
||||
function render(config: any) {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<ContextForm config={config} onChange={onChange} overriddenByEnv={{}} />);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
function renderStateful(config: any) {
|
||||
const onChange = vi.fn();
|
||||
const utils = renderStatefulForm(ContextForm, config, { onChangeSpy: onChange });
|
||||
return { onChange, ...utils };
|
||||
}
|
||||
|
||||
const baseThresholds = [
|
||||
{ ratio: 0.7, action: 'warn' },
|
||||
{ ratio: 0.85, action: 'prompt' },
|
||||
{ ratio: 0.95, action: 'force_transition' },
|
||||
];
|
||||
|
||||
describe('ContextForm', () => {
|
||||
it('renders default thresholds when none provided', () => {
|
||||
render({ context: {} });
|
||||
const ratioInputs = screen.getAllByRole('spinbutton') as HTMLInputElement[];
|
||||
// 1 limitTokens + 3 threshold ratios.
|
||||
expect(ratioInputs).toHaveLength(4);
|
||||
// Three action selects.
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('serializes a ratio edit by rewriting the full thresholds array with a numeric ratio', async () => {
|
||||
const { getConfig } = renderStateful({ context: { thresholds: baseThresholds } });
|
||||
const ratioInputs = screen.getAllByRole('spinbutton');
|
||||
// ratioInputs[0] is limitTokens; the first threshold ratio is index 1.
|
||||
await userEvent.clear(ratioInputs[1]);
|
||||
await userEvent.type(ratioInputs[1], '0.5');
|
||||
const value = getConfig().context.thresholds;
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
expect(value).toHaveLength(3);
|
||||
expect(value[0]).toEqual({ ratio: 0.5, action: 'warn' });
|
||||
expect(typeof value[0].ratio).toBe('number');
|
||||
// Other rows preserved.
|
||||
expect(value[1]).toEqual({ ratio: 0.85, action: 'prompt' });
|
||||
});
|
||||
|
||||
it('serializes an action edit while preserving that row ratio', async () => {
|
||||
const { onChange } = render({ context: { thresholds: baseThresholds } });
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
await userEvent.selectOptions(selects[1], 'force_transition');
|
||||
const [path, value] = onChange.mock.calls.at(-1)!;
|
||||
expect(path).toBe('context.thresholds');
|
||||
expect(value[1]).toEqual({ ratio: 0.85, action: 'force_transition' });
|
||||
});
|
||||
|
||||
it('serializes limitTokens as a number, and undefined when emptied', async () => {
|
||||
const { onChange } = render({ context: { limitTokens: 8000 } });
|
||||
const limit = screen.getAllByRole('spinbutton')[0];
|
||||
await userEvent.clear(limit);
|
||||
expect(onChange).toHaveBeenLastCalledWith('context.limitTokens', undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ExecutionForm (settings batch A).
|
||||
*
|
||||
* Focus: the comma-separated "Backoff Seconds" serialization (split, trim,
|
||||
* Number, filter NaN), number coercion for concurrency/maxMovements with the
|
||||
* empty -> undefined contract, and ENV-override disabling of the concurrency
|
||||
* field.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { ExecutionForm } from './ExecutionForm';
|
||||
|
||||
function render(config: any = {}, overriddenByEnv: Record<string, boolean> = {}) {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ExecutionForm config={config} onChange={onChange} overriddenByEnv={overriddenByEnv} />,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
function renderStateful(config: any = {}) {
|
||||
const onChange = vi.fn();
|
||||
const utils = renderStatefulForm(ExecutionForm, config, { onChangeSpy: onChange });
|
||||
return { onChange, ...utils };
|
||||
}
|
||||
|
||||
describe('ExecutionForm', () => {
|
||||
it('renders the default backoff seconds joined as a comma string', () => {
|
||||
render({});
|
||||
const backoff = screen.getByDisplayValue('60, 300, 900');
|
||||
expect(backoff).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('serializes concurrency to a number and undefined when emptied', async () => {
|
||||
const { onChange, getConfig } = renderStateful({ concurrency: 4 });
|
||||
const concurrency = screen.getAllByRole('spinbutton')[0];
|
||||
await userEvent.clear(concurrency);
|
||||
await userEvent.type(concurrency, '2');
|
||||
expect(getConfig().concurrency).toBe(2);
|
||||
await userEvent.clear(concurrency);
|
||||
expect(getConfig().concurrency).toBeUndefined();
|
||||
expect(onChange).toHaveBeenLastCalledWith('concurrency', undefined);
|
||||
});
|
||||
|
||||
it('serializes backoff seconds: split on comma, trim, Number, drop NaN', () => {
|
||||
// The field is a controlled text input that re-serializes to a number[] on
|
||||
// every change; fire ONE change with the whole value (like a paste) so the
|
||||
// parse is exercised on the complete string. Includes whitespace + a
|
||||
// non-numeric token that must be dropped.
|
||||
const { getConfig } = renderStateful({ retry: { backoffSeconds: [60] } });
|
||||
const backoff = screen.getByDisplayValue('60');
|
||||
fireEvent.change(backoff, { target: { value: '10, 20 , abc, 30' } });
|
||||
expect(getConfig().retry.backoffSeconds).toEqual([10, 20, 30]);
|
||||
});
|
||||
|
||||
it('disables the concurrency field when overridden by env', () => {
|
||||
render({ concurrency: 4 }, { concurrency: true });
|
||||
const concurrency = screen.getAllByRole('spinbutton')[0] as HTMLInputElement;
|
||||
expect(concurrency).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
@@ -321,6 +322,7 @@ export function GatewayKeysSection({ showToast }: Props) {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for LlmWorkersForm (settings batch A).
|
||||
*
|
||||
* Focus: the worker-array editing serialization that has real logic —
|
||||
* - addWorker pushes a default-shaped worker onto llm.workers
|
||||
* - removeWorker / moveWorker rewrite the array correctly
|
||||
* - the connectionType <select> keeps the legacy `proxy` flag in sync
|
||||
* (aao_gateway -> proxy:true, direct -> proxy:undefined)
|
||||
* - retry.backoffMs serialization (StringArrayEditor strings -> numbers, NaN dropped)
|
||||
* - the self-loop endpoint warning heuristic (localhost) for gateway rows
|
||||
*
|
||||
* ModelSelect uses raw fetch for model discovery; we stub global fetch so no
|
||||
* real network happens.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { LlmWorkersForm } from './LlmWorkersForm';
|
||||
|
||||
function render(config: any) {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<LlmWorkersForm config={config} onChange={onChange} overriddenByEnv={{}} />);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Never let ModelSelect hit the network.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => ({ ok: false, status: 500, json: async () => ({}) })) as any,
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('LlmWorkersForm', () => {
|
||||
it('shows the empty state when there are no workers', () => {
|
||||
render({ llm: { workers: [] } });
|
||||
// empty -> i18n key 'llmWorkers.empty' (not initialized) appears as text.
|
||||
expect(screen.getByText('llmWorkers.empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('addWorker appends a default-shaped worker to llm.workers', async () => {
|
||||
const { onChange } = render({ llm: { workers: [] } });
|
||||
await userEvent.click(screen.getByRole('button', { name: 'llmWorkers.addWorker' }));
|
||||
const [path, value] = onChange.mock.calls.at(-1)!;
|
||||
expect(path).toBe('llm.workers');
|
||||
expect(value).toHaveLength(1);
|
||||
expect(value[0]).toMatchObject({
|
||||
connectionType: 'direct',
|
||||
enabled: true,
|
||||
maxConcurrency: 1,
|
||||
roles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('removeWorker drops the row from the array', async () => {
|
||||
const { onChange } = render({
|
||||
llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }, { id: 'w2', endpoint: 'http://b/v1' }] },
|
||||
});
|
||||
await userEvent.click(screen.getAllByTitle('llmWorkers.removeWorker')[0]);
|
||||
const [path, value] = onChange.mock.calls.at(-1)!;
|
||||
expect(path).toBe('llm.workers');
|
||||
expect(value).toHaveLength(1);
|
||||
expect(value[0].id).toBe('w2');
|
||||
});
|
||||
|
||||
it('moveWorker reorders rows', async () => {
|
||||
const { onChange } = render({
|
||||
llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }, { id: 'w2', endpoint: 'http://b/v1' }] },
|
||||
});
|
||||
// Second row's move-up button.
|
||||
await userEvent.click(screen.getAllByTitle('llmWorkers.moveUp')[1]);
|
||||
const [, value] = onChange.mock.calls.at(-1)!;
|
||||
expect(value.map((w: any) => w.id)).toEqual(['w2', 'w1']);
|
||||
});
|
||||
|
||||
it('keeps the legacy proxy flag in sync with connectionType', async () => {
|
||||
const { onChange } = render({
|
||||
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://a/v1' }] },
|
||||
});
|
||||
// The connection-type <select> is the only combobox in the row.
|
||||
const select = screen.getByRole('combobox');
|
||||
await userEvent.selectOptions(select, 'aao_gateway');
|
||||
let [path, value] = onChange.mock.calls.at(-1)!;
|
||||
expect(path).toBe('llm.workers');
|
||||
expect(value[0]).toMatchObject({ connectionType: 'aao_gateway', proxy: true });
|
||||
|
||||
await userEvent.selectOptions(select, 'direct');
|
||||
[path, value] = onChange.mock.calls.at(-1)!;
|
||||
expect(value[0].connectionType).toBe('direct');
|
||||
expect(value[0].proxy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('serializes retry.backoffMs from string chips to numbers, dropping NaN', async () => {
|
||||
const { onChange } = render({ llm: { workers: [], retry: { backoffMs: [] } } });
|
||||
// The backoff chip editor input has placeholder "2000".
|
||||
const input = screen.getByPlaceholderText('2000');
|
||||
await userEvent.type(input, 'abc{enter}');
|
||||
// 'abc' -> NaN -> dropped, so the array stays empty.
|
||||
let call = onChange.mock.calls.find(([p]) => p === 'llm.retry.backoffMs');
|
||||
expect(call?.[1]).toEqual([]);
|
||||
|
||||
await userEvent.type(input, '1500{enter}');
|
||||
call = onChange.mock.calls.filter(([p]) => p === 'llm.retry.backoffMs').at(-1);
|
||||
expect(call?.[1]).toEqual([1500]);
|
||||
});
|
||||
|
||||
it('shows the self-loop warning for a gateway worker pointing at localhost', () => {
|
||||
render({
|
||||
llm: { workers: [{ id: 'w1', connectionType: 'aao_gateway', endpoint: 'http://localhost:9876/v1' }] },
|
||||
});
|
||||
expect(screen.getByText('llmWorkers.selfLoopWarn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT show the self-loop warning for a direct remote endpoint', () => {
|
||||
render({
|
||||
llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://remote-box:11434/v1' }] },
|
||||
});
|
||||
expect(screen.queryByText('llmWorkers.selfLoopWarn')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for MetricsForm (settings → llm.metrics.* + gateway.metrics.*).
|
||||
*
|
||||
* Focus: the two MetricsBlock instances write to distinct config paths,
|
||||
* checkbox + text serialization (empty → undefined), and reading nested
|
||||
* existing values.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
import { MetricsForm } from './MetricsForm';
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
function render(config: any, onChange = vi.fn()) {
|
||||
renderWithProviders(
|
||||
<MetricsForm config={config} onChange={onChange} overriddenByEnv={{}} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('MetricsForm', () => {
|
||||
it('reads nested enabled state for each block independently', () => {
|
||||
render({
|
||||
llm: { metrics: { enabled: true } },
|
||||
gateway: { metrics: { enabled: false } },
|
||||
});
|
||||
const enableBoxes = screen.getAllByLabelText('Enable') as HTMLInputElement[];
|
||||
expect(enableBoxes).toHaveLength(2);
|
||||
expect(enableBoxes[0].checked).toBe(true); // worker (llm.metrics)
|
||||
expect(enableBoxes[1].checked).toBe(false); // gateway.metrics
|
||||
});
|
||||
|
||||
it('toggles the worker-metrics enable checkbox to llm.metrics.enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({});
|
||||
const enableBoxes = screen.getAllByLabelText('Enable');
|
||||
await user.click(enableBoxes[0]);
|
||||
expect(onChange).toHaveBeenCalledWith('llm.metrics.enabled', true);
|
||||
});
|
||||
|
||||
it('toggles the gateway-metrics enable checkbox to gateway.metrics.enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({});
|
||||
const enableBoxes = screen.getAllByLabelText('Enable');
|
||||
await user.click(enableBoxes[1]);
|
||||
expect(onChange).toHaveBeenCalledWith('gateway.metrics.enabled', true);
|
||||
});
|
||||
|
||||
it('serializes a cleared prefix to undefined', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ llm: { metrics: { prefix: 'aao_worker' } } });
|
||||
const prefixInput = screen.getByDisplayValue('aao_worker');
|
||||
await user.clear(prefixInput);
|
||||
expect(onChange).toHaveBeenLastCalledWith('llm.metrics.prefix', undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for PathsStorageForm (settings → storage.*).
|
||||
*
|
||||
* Focus: text path passthrough (empty → undefined), numeric serialization
|
||||
* with defaults (taskUploadMaxSizeMb default 50, trashRetentionDays default
|
||||
* 30), and ENV-override gating that disables the worktree field.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
import { PathsStorageForm } from './PathsStorageForm';
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
function render(config: any, overriddenByEnv: Record<string, boolean> = {}, onChange = vi.fn()) {
|
||||
renderWithProviders(
|
||||
<PathsStorageForm config={config} onChange={onChange} overriddenByEnv={overriddenByEnv} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('PathsStorageForm', () => {
|
||||
it('renders existing storage values and numeric defaults', () => {
|
||||
render({ storage: { worktreeDir: '/srv/worktrees' } });
|
||||
expect(screen.getByDisplayValue('/srv/worktrees')).toBeInTheDocument();
|
||||
// defaults appear when unset
|
||||
expect(screen.getByDisplayValue('50')).toBeInTheDocument(); // taskUploadMaxSizeMb default
|
||||
expect(screen.getByDisplayValue('30')).toBeInTheDocument(); // trashRetentionDays default
|
||||
});
|
||||
|
||||
it('writes storage.customPiecesDir, undefined when cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ storage: { customPiecesDir: '/old' } });
|
||||
const input = screen.getByDisplayValue('/old');
|
||||
await user.clear(input);
|
||||
expect(onChange).toHaveBeenLastCalledWith('storage.customPiecesDir', undefined);
|
||||
});
|
||||
|
||||
it('serializes trashRetentionDays as a Number', () => {
|
||||
const onChange = render({ storage: {} });
|
||||
// default render shows 30
|
||||
const input = screen.getByDisplayValue('30');
|
||||
fireEvent.change(input, { target: { value: '7' } });
|
||||
expect(onChange).toHaveBeenCalledWith('storage.trashRetentionDays', 7);
|
||||
});
|
||||
|
||||
it('disables the worktree field when overridden by env', () => {
|
||||
render({ storage: { worktreeDir: '/srv/worktrees' } }, { 'storage.worktreeDir': true });
|
||||
const input = screen.getByDisplayValue('/srv/worktrees') as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('supports the legacy flat worktreeDir env override key', () => {
|
||||
render({ storage: { worktreeDir: '/srv/worktrees' } }, { worktreeDir: true });
|
||||
const input = screen.getByDisplayValue('/srv/worktrees') as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for PushNotificationsForm (settings → notifications.push.*).
|
||||
*
|
||||
* Focus: enable checkbox gating, URL/text passthrough (empty → undefined),
|
||||
* and numeric serialization for payloadMaxBytes / queueConcurrency.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
import { PushNotificationsForm } from './PushNotificationsForm';
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
function render(config: any, onChange = vi.fn()) {
|
||||
renderWithProviders(
|
||||
<PushNotificationsForm config={config} onChange={onChange} overriddenByEnv={{}} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('PushNotificationsForm', () => {
|
||||
it('renders with realistic existing push config', () => {
|
||||
render({
|
||||
notifications: {
|
||||
push: { enabled: true, vapidSubject: 'https://maestro.example.com/', queueConcurrency: 8 },
|
||||
},
|
||||
});
|
||||
expect(screen.getByDisplayValue('https://maestro.example.com/')).toBeInTheDocument();
|
||||
const enable = screen.getByLabelText('Enable Web Push') as HTMLInputElement;
|
||||
expect(enable.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to disabled when push config is absent', () => {
|
||||
render({});
|
||||
const enable = screen.getByLabelText('Enable Web Push') as HTMLInputElement;
|
||||
expect(enable.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles the enable switch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({});
|
||||
await user.click(screen.getByLabelText('Enable Web Push'));
|
||||
expect(onChange).toHaveBeenCalledWith('notifications.push.enabled', true);
|
||||
});
|
||||
|
||||
it('writes vapidSubject text, undefined when cleared', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ notifications: { push: { vapidSubject: 'mailto:[email protected]' } } });
|
||||
const input = screen.getByDisplayValue('mailto:[email protected]');
|
||||
await user.clear(input);
|
||||
expect(onChange).toHaveBeenLastCalledWith('notifications.push.vapidSubject', undefined);
|
||||
});
|
||||
|
||||
it('serializes payloadMaxBytes to a Number', () => {
|
||||
const onChange = render({});
|
||||
const numberInputs = screen.getAllByRole('spinbutton');
|
||||
// first number field is payloadMaxBytes
|
||||
fireEvent.change(numberInputs[0], { target: { value: '3072' } });
|
||||
expect(onChange).toHaveBeenCalledWith('notifications.push.payloadMaxBytes', 3072);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ export function RulesTable({ rules, movementNames, onChange, disabled = false }:
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 mb-1">rules</label>
|
||||
{rules.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mb-2">
|
||||
<thead>
|
||||
<tr className="text-xs text-slate-500">
|
||||
@@ -80,6 +81,7 @@ export function RulesTable({ rules, movementNames, onChange, disabled = false }:
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for SafetyForm (settings batch A).
|
||||
*
|
||||
* Focus: number coercion (text input -> Number), boolean checkbox toggles,
|
||||
* the bash-sandbox <select>, the nested historySummarization.* path, and the
|
||||
* "enabled !== false" default-on semantics for history summarization.
|
||||
*
|
||||
* i18n is NOT initialized in the test env, so react-i18next's t() returns the
|
||||
* raw key — fine for behavior tests (we assert on roles/values, not labels).
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { SafetyForm } from './SafetyForm';
|
||||
|
||||
function render(config: any = { safety: {} }) {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(<SafetyForm config={config} onChange={onChange} overriddenByEnv={{}} />);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
function renderStateful(config: any = { safety: {} }) {
|
||||
const onChange = vi.fn();
|
||||
const utils = renderStatefulForm(SafetyForm, config, { onChangeSpy: onChange });
|
||||
return { onChange, ...utils };
|
||||
}
|
||||
|
||||
describe('SafetyForm', () => {
|
||||
it('renders with empty safety config (defaults visible)', () => {
|
||||
render({ safety: {} });
|
||||
// Max Iterations default 200 shown in the first number field.
|
||||
const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[];
|
||||
expect(numbers[0]).toHaveValue(200);
|
||||
// bashSandbox select defaults to 'auto'
|
||||
expect(screen.getByRole('combobox')).toHaveValue('auto');
|
||||
});
|
||||
|
||||
it('coerces a typed number field to a Number via onChange', async () => {
|
||||
const { onChange, getConfig } = renderStateful({ safety: { maxIterations: 200 } });
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
await userEvent.clear(numbers[0]);
|
||||
await userEvent.type(numbers[0], '7');
|
||||
// Live config reflects a real (numeric) value, not a string.
|
||||
expect(getConfig().safety.maxIterations).toBe(7);
|
||||
expect(onChange).toHaveBeenCalledWith('safety.maxIterations', expect.any(Number));
|
||||
});
|
||||
|
||||
it('serializes promptGuardRatio as undefined when cleared (empty -> undefined)', async () => {
|
||||
const { onChange } = render({ safety: { promptGuardRatio: 0.8 } });
|
||||
// promptGuardRatio is the 4th number input.
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
await userEvent.clear(numbers[3]);
|
||||
expect(onChange).toHaveBeenLastCalledWith('safety.promptGuardRatio', undefined);
|
||||
});
|
||||
|
||||
it('toggles bashUnrestricted checkbox to a boolean', async () => {
|
||||
const { onChange } = render({ safety: { bashUnrestricted: false } });
|
||||
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
||||
// bashUnrestricted is the first checkbox.
|
||||
await userEvent.click(checkboxes[0]);
|
||||
expect(onChange).toHaveBeenCalledWith('safety.bashUnrestricted', true);
|
||||
});
|
||||
|
||||
it('writes the nested historySummarization path and treats enabled as default-on', async () => {
|
||||
// enabled defaults to true (checked) when undefined: checked={enabled !== false}.
|
||||
const { onChange } = render({ safety: {} });
|
||||
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
||||
// history enabled is the 3rd checkbox (bashUnrestricted, bashAllowNetwork, historyEnabled).
|
||||
const historyToggle = checkboxes[2];
|
||||
expect(historyToggle).toBeChecked();
|
||||
await userEvent.click(historyToggle);
|
||||
expect(onChange).toHaveBeenCalledWith('safety.historySummarization.enabled', false);
|
||||
});
|
||||
|
||||
it('changes the bash sandbox select value', async () => {
|
||||
const { onChange } = render({ safety: { bashSandbox: 'auto' } });
|
||||
await userEvent.selectOptions(screen.getByRole('combobox'), 'always');
|
||||
expect(onChange).toHaveBeenCalledWith('safety.bashSandbox', 'always');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for SearchFilterForm (settings → searchFilter.*).
|
||||
*
|
||||
* Focus: blocked-patterns StringArrayEditor add, and the four auto-block
|
||||
* checkboxes mapping to searchFilter.autoBlock.<key>.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
import { SearchFilterForm } from './SearchFilterForm';
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
function render(config: any, onChange = vi.fn()) {
|
||||
renderWithProviders(
|
||||
<SearchFilterForm config={config} onChange={onChange} overriddenByEnv={{}} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('SearchFilterForm', () => {
|
||||
it('renders existing blocked patterns and auto-block state', () => {
|
||||
render({
|
||||
searchFilter: {
|
||||
blockedPatterns: ['secret\\.internal'],
|
||||
autoBlock: { privateIp: true, email: false },
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('secret\\.internal')).toBeInTheDocument();
|
||||
const privateIp = screen.getByLabelText('Private IP') as HTMLInputElement;
|
||||
expect(privateIp.checked).toBe(true);
|
||||
const email = screen.getByLabelText('Email address') as HTMLInputElement;
|
||||
expect(email.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('adds a blocked pattern via the StringArrayEditor', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ searchFilter: { blockedPatterns: ['a'] } });
|
||||
const input = screen.getByPlaceholderText('regex pattern');
|
||||
await user.type(input, 'b\\.c');
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onChange).toHaveBeenCalledWith('searchFilter.blockedPatterns', ['a', 'b\\.c']);
|
||||
});
|
||||
|
||||
it('toggles each auto-block checkbox to the correct nested path', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ searchFilter: {} });
|
||||
await user.click(screen.getByLabelText('Phone number'));
|
||||
expect(onChange).toHaveBeenCalledWith('searchFilter.autoBlock.phone', true);
|
||||
await user.click(screen.getByLabelText('Internal domain'));
|
||||
expect(onChange).toHaveBeenCalledWith('searchFilter.autoBlock.internalDomain', true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ServerTlsForm (settings → server.tls.*).
|
||||
*
|
||||
* Focus: port number serialization (parseInt, NaN → undefined), the
|
||||
* comma-separated HTTP-redirect-port list (single → number, many → array,
|
||||
* empty → undefined), and the enable-HTTPS checkbox round-trip.
|
||||
*
|
||||
* Note: these forms are controlled by an external `config` prop, but our test
|
||||
* `onChange` is a mock that never writes back, so the input's value never
|
||||
* advances across keystrokes. To exercise multi-character serialization we use
|
||||
* `fireEvent.change` to deliver the whole value in a single change event (which
|
||||
* is exactly the one transformation the component performs).
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
import { ServerTlsForm } from './ServerTlsForm';
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
function render(config: any, onChange = vi.fn()) {
|
||||
renderWithProviders(
|
||||
<ServerTlsForm config={config} onChange={onChange} overriddenByEnv={{}} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
// The redirect-port field placeholder contains a double space; match loosely.
|
||||
const redirectPortField = () => screen.getByPlaceholderText(/9080.*or 80/);
|
||||
|
||||
describe('ServerTlsForm', () => {
|
||||
it('renders existing server.tls values', () => {
|
||||
render({
|
||||
server: {
|
||||
port: 9876,
|
||||
tls: { enabled: true, certFile: '/etc/ssl/server.pem', httpRedirectPort: [80, 9876] },
|
||||
},
|
||||
});
|
||||
expect(screen.getByDisplayValue('9876')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('/etc/ssl/server.pem')).toBeInTheDocument();
|
||||
// array redirect ports rendered joined
|
||||
expect(screen.getByDisplayValue('80, 9876')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('serializes server.port via parseInt', () => {
|
||||
const onChange = render({ server: { tls: {} } });
|
||||
const portInput = screen.getByPlaceholderText('9876');
|
||||
fireEvent.change(portInput, { target: { value: '8443' } });
|
||||
expect(onChange).toHaveBeenCalledWith('server.port', 8443);
|
||||
});
|
||||
|
||||
it('parses a single redirect port to a number', () => {
|
||||
const onChange = render({ server: { tls: {} } });
|
||||
fireEvent.change(redirectPortField(), { target: { value: '80' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith('server.tls.httpRedirectPort', 80);
|
||||
});
|
||||
|
||||
it('parses a comma-separated redirect port list to an array', () => {
|
||||
const onChange = render({ server: { tls: {} } });
|
||||
fireEvent.change(redirectPortField(), { target: { value: '80, 9876' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith('server.tls.httpRedirectPort', [80, 9876]);
|
||||
});
|
||||
|
||||
it('clears redirect port to undefined when no valid number is present', () => {
|
||||
const onChange = render({ server: { tls: { httpRedirectPort: 80 } } });
|
||||
fireEvent.change(redirectPortField(), { target: { value: 'abc' } });
|
||||
expect(onChange).toHaveBeenLastCalledWith('server.tls.httpRedirectPort', undefined);
|
||||
});
|
||||
|
||||
it('toggles the Serve-over-HTTPS checkbox', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = render({ server: { tls: {} } });
|
||||
const enableCheckbox = screen.getByLabelText('Serve over HTTPS');
|
||||
await user.click(enableCheckbox);
|
||||
expect(onChange).toHaveBeenCalledWith('server.tls.enabled', true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* SkillsForm.tsx — Settings > Skills tab
|
||||
* SkillsForm.tsx — Skills panel (mounted under User Folder, not Settings)
|
||||
*
|
||||
* Two-column list + detail layout for browsing, creating, editing, and
|
||||
* deleting agent skills. Supports installing skills from a URL.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ToolsForm (settings batch A).
|
||||
*
|
||||
* This is the legacy grab-bag Tools form (Web / Vision / X / Maps / ... sub-tabs).
|
||||
* Focus: tab switching shows the right fields, number coercion on timeouts, the
|
||||
* userScriptsEnabled checkbox boolean, and that visibleTabs narrows the tab set.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
|
||||
import { ToolsForm } from './ToolsForm';
|
||||
|
||||
function render(config: any = { tools: {} }, visibleTabs?: any) {
|
||||
const onChange = vi.fn();
|
||||
renderWithProviders(
|
||||
<ToolsForm config={config} onChange={onChange} overriddenByEnv={{}} visibleTabs={visibleTabs} />,
|
||||
);
|
||||
return { onChange };
|
||||
}
|
||||
|
||||
function renderStateful(config: any = { tools: {} }) {
|
||||
const onChange = vi.fn();
|
||||
// ToolsForm ignores overriddenByEnv; renderStatefulForm passes {} which is fine.
|
||||
const utils = renderStatefulForm(ToolsForm as any, config, { onChangeSpy: onChange });
|
||||
return { onChange, ...utils };
|
||||
}
|
||||
|
||||
describe('ToolsForm', () => {
|
||||
it('renders the Web tab first and writes searxngUrl as a string', async () => {
|
||||
const { getConfig } = renderStateful({ tools: {} });
|
||||
// SearXNG URL is the first text input on the Web tab; SSRF editor confirms tab.
|
||||
expect(screen.getByPlaceholderText('hostname or IP address')).toBeInTheDocument();
|
||||
const textInputs = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
await userEvent.type(textInputs[0], 'http://searx.local');
|
||||
expect(getConfig().tools.searxngUrl).toBe('http://searx.local');
|
||||
});
|
||||
|
||||
it('coerces the webfetch timeout to a number', async () => {
|
||||
const { getConfig } = renderStateful({ tools: { webfetchTimeout: 30 } });
|
||||
const numbers = screen.getAllByRole('spinbutton');
|
||||
await userEvent.clear(numbers[0]);
|
||||
await userEvent.type(numbers[0], '45');
|
||||
expect(getConfig().tools.webfetchTimeout).toBe(45);
|
||||
expect(typeof getConfig().tools.webfetchTimeout).toBe('number');
|
||||
});
|
||||
|
||||
it('switches to the X tab and shows X-specific fields', async () => {
|
||||
render({ tools: {} });
|
||||
// The X tab label.
|
||||
await userEvent.click(screen.getByRole('button', { name: 'X / Twitter' }));
|
||||
expect(screen.getByText('X Auth Token')).toBeInTheDocument();
|
||||
expect(screen.getByText('X ct0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles userScriptsEnabled to a boolean on the User Folder tab', async () => {
|
||||
const { onChange } = render({ tools: { userScriptsEnabled: false } });
|
||||
await userEvent.click(screen.getByRole('button', { name: 'User Folder' }));
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
await userEvent.click(checkbox);
|
||||
expect(onChange).toHaveBeenCalledWith('tools.userScriptsEnabled', true);
|
||||
});
|
||||
|
||||
it('respects visibleTabs to narrow the visible sub-tabs', () => {
|
||||
render({ tools: {} }, ['web']);
|
||||
expect(screen.getByRole('button', { name: 'Web' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'X / Twitter' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for EmptyState's three render branches:
|
||||
* 1. compact → title + optional hint + optional action (no steps list)
|
||||
* 2. hint-only (hint, no description, no onCreateTask) → centered title+hint
|
||||
* 3. default → title + optional description + the 3-step onboarding list +
|
||||
* optional "create task" button.
|
||||
* Uses the real i18n instance (auto-initialized on import) for the layout ns.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import '../../i18n'; // initializes i18next so useTranslation('layout') resolves
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('compact branch: shows title + hint + action, no steps list', () => {
|
||||
render(
|
||||
<EmptyState compact title="Nothing here" hint="add something" action={<span>act</span>} />,
|
||||
);
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||
expect(screen.getByText('add something')).toBeInTheDocument();
|
||||
expect(screen.getByText('act')).toBeInTheDocument();
|
||||
// compact branch never renders the numbered onboarding list
|
||||
expect(screen.queryByRole('listitem')).toBeNull();
|
||||
});
|
||||
|
||||
it('hint-only branch: title + hint, still no steps list', () => {
|
||||
render(<EmptyState title="Pick a thread" hint="choose from the left" />);
|
||||
expect(screen.getByText('Pick a thread')).toBeInTheDocument();
|
||||
expect(screen.getByText('choose from the left')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('listitem')).toBeNull();
|
||||
});
|
||||
|
||||
it('default branch: renders title, description, and the 3 onboarding steps', () => {
|
||||
render(<EmptyState title="Welcome" description="this is the description" />);
|
||||
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
||||
expect(screen.getByText('this is the description')).toBeInTheDocument();
|
||||
// 3 numbered steps from the layout i18n namespace
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('default branch: renders + wires the create button when onCreateTask given', async () => {
|
||||
const onCreateTask = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<EmptyState title="Welcome" onCreateTask={onCreateTask} />);
|
||||
const btn = screen.getByRole('button');
|
||||
await user.click(btn);
|
||||
expect(onCreateTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('default branch: no create button when onCreateTask omitted', () => {
|
||||
render(<EmptyState title="Welcome" description="d" />);
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
export function LoadingSpinner({ label = 'Loading...' }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 p-4 text-[13px] text-slate-500">
|
||||
<div className="w-4 h-4 border-2 border-slate-200 border-t-accent rounded-full animate-spin" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for StatChip: a label/value pill whose value styling branches
|
||||
* on the value type (number → big extrabold, string → smaller truncating text)
|
||||
* unless an explicit valueClassName overrides both.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StatChip } from './StatChip';
|
||||
|
||||
describe('StatChip', () => {
|
||||
it('renders the label and value', () => {
|
||||
render(<StatChip label="Tasks" value={42} />);
|
||||
expect(screen.getByText('Tasks')).toBeInTheDocument();
|
||||
expect(screen.getByText('42')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses the number styling branch for a numeric value', () => {
|
||||
render(<StatChip label="Count" value={7} />);
|
||||
const valueEl = screen.getByText('7');
|
||||
expect(valueEl).toHaveClass('text-lg', 'font-extrabold');
|
||||
expect(valueEl).not.toHaveClass('truncate');
|
||||
});
|
||||
|
||||
it('uses the string styling branch (truncate) for a string value', () => {
|
||||
render(<StatChip label="Owner" value="alice" />);
|
||||
const valueEl = screen.getByText('alice');
|
||||
expect(valueEl).toHaveClass('truncate');
|
||||
expect(valueEl).not.toHaveClass('text-lg');
|
||||
});
|
||||
|
||||
it('honors an explicit valueClassName over the type-based branch', () => {
|
||||
render(<StatChip label="X" value={99} valueClassName="custom-value-class" />);
|
||||
const valueEl = screen.getByText('99');
|
||||
expect(valueEl).toHaveClass('custom-value-class');
|
||||
// type-based classes are not applied when overridden
|
||||
expect(valueEl).not.toHaveClass('text-lg');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for StatusBadge: it maps a job status string to a tone
|
||||
* (bg/fg) and a human label. Branching lives in `statusTone` (per-status
|
||||
* colors) and `formatStatusLabel` (known label vs. raw passthrough), both of
|
||||
* which the badge wires straight into the rendered <span>'s style + text.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { statusTone } from '../../lib/utils';
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it('renders the human label for a known status', () => {
|
||||
render(<StatusBadge status="succeeded" />);
|
||||
// COLUMN_LABELS maps succeeded -> "Done"
|
||||
expect(screen.getByText('Done')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw status string for an unknown status', () => {
|
||||
render(<StatusBadge status="totally_unknown" />);
|
||||
expect(screen.getByText('totally_unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the running tone colors as inline style', () => {
|
||||
render(<StatusBadge status="running" />);
|
||||
const tone = statusTone('running');
|
||||
const badge = screen.getByText('Running');
|
||||
// jsdom normalizes color values; assert via the style object the component set.
|
||||
expect(badge).toHaveStyle({ background: tone.bg, color: tone.fg });
|
||||
});
|
||||
|
||||
it('uses a distinct tone for failed vs succeeded (branching)', () => {
|
||||
const failed = statusTone('failed');
|
||||
const succeeded = statusTone('succeeded');
|
||||
expect(failed.bg).not.toBe(succeeded.bg);
|
||||
|
||||
render(<StatusBadge status="failed" />);
|
||||
expect(screen.getByText('Failed')).toHaveStyle({ background: failed.bg });
|
||||
});
|
||||
|
||||
it('merges an extra className onto the badge', () => {
|
||||
render(<StatusBadge status="queued" className="my-extra-class" />);
|
||||
expect(screen.getByText('Inbox')).toHaveClass('my-extra-class');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Pure-function tests for shouldConfirmWrite.
|
||||
* No DOM render needed — these run in node env.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shouldConfirmWrite } from './app-bridge';
|
||||
|
||||
describe('shouldConfirmWrite', () => {
|
||||
it('returns false when autoApprove=true (always bypass confirm)', () => {
|
||||
// Even a path that would normally require confirm is bypassed.
|
||||
expect(shouldConfirmWrite('my-app', 'some/arbitrary/file.txt', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when path is under output/ (allowlisted, no confirm needed)', () => {
|
||||
expect(shouldConfirmWrite('my-app', 'output/report.csv', false)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when autoApprove=false and path is NOT allowlisted', () => {
|
||||
// A path not under output/ or apps/{appName}/data/ must trigger a confirm.
|
||||
expect(shouldConfirmWrite('my-app', 'some/arbitrary/file.txt', false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fetchSpaceFileContent, fetchSpaceFiles, writeSpaceFile, deleteSpaceFiles, getSpaceFileRawUrl } from '../../api';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import type { AppFileGateway } from './app-file-gateway';
|
||||
import {
|
||||
resolveAppPath,
|
||||
isWriteWithoutConfirm,
|
||||
shouldConfirmWrite,
|
||||
isAppBridgeRequest,
|
||||
type AppBridgeResponse,
|
||||
} from './app-bridge';
|
||||
@@ -34,12 +35,26 @@ import {
|
||||
*/
|
||||
|
||||
interface AppRunnerProps {
|
||||
spaceId: string;
|
||||
/**
|
||||
* File I/O gateway. The authenticated space gateway exposes writeFile/deleteFile;
|
||||
* the public (read-only) app-share gateway omits them. AppRunner keys the
|
||||
* write/delete bridge paths off their presence — no separate read-only flag needed
|
||||
* for I/O. `canWrite` only affects the header copy + whether write confirms are even
|
||||
* attempted (defaults to true when a writeFile is present).
|
||||
*/
|
||||
gateway: AppFileGateway;
|
||||
/** App folder name under apps/ (e.g. "invoice-gen" for apps/invoice-gen/index.html). */
|
||||
appName: string;
|
||||
/** Workspace-relative path to the app's entry HTML (e.g. "apps/foo/index.html"). */
|
||||
entryPath: string;
|
||||
onClose: () => void;
|
||||
/** Explicit read/write hint for the header copy. Defaults to gateway.writeFile presence. */
|
||||
canWrite?: boolean;
|
||||
/**
|
||||
* When true, skip all write/delete confirm dialogs (headless E2E harness only).
|
||||
* In production this prop is omitted (defaults to false), so behavior is unchanged.
|
||||
*/
|
||||
autoApproveWrites?: boolean;
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
@@ -78,7 +93,7 @@ function injectCsp(html: string): string {
|
||||
return `${meta}${html}`;
|
||||
}
|
||||
|
||||
function rewriteRelativeAssets(html: string, spaceId: string, appName: string): string {
|
||||
function rewriteRelativeAssets(html: string, appName: string, rawUrl: (path: string) => string): string {
|
||||
const appDir = `apps/${appName}`;
|
||||
return html.replace(/\b(src|href)\s*=\s*(["'])(.*?)\2/gi, (m, attr: string, q: string, url: string) => {
|
||||
const u = url.trim();
|
||||
@@ -98,32 +113,37 @@ function rewriteRelativeAssets(html: string, spaceId: string, appName: string):
|
||||
} catch {
|
||||
return m; // unsafe relative → leave untouched (will simply fail to load)
|
||||
}
|
||||
return `${attr}=${q}${getSpaceFileRawUrl(spaceId, rel)}${q}`;
|
||||
return `${attr}=${q}${rawUrl(rel)}${q}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerProps) {
|
||||
export function AppRunner({ gateway, appName, entryPath, onClose, canWrite, autoApproveWrites = false }: AppRunnerProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [srcDoc, setSrcDoc] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [confirm, setConfirm] = useState<ConfirmState | null>(null);
|
||||
|
||||
// Fetch + prepare the app HTML once per (spaceId, entryPath).
|
||||
// 書き込み可否は gateway.writeFile の有無で決まる(read-only gateway は未定義)。
|
||||
// canWrite は header コピーの上書きヒントのみ(既定は writeFile の存在)。
|
||||
const writable = canWrite ?? typeof gateway.writeFile === 'function';
|
||||
|
||||
// Fetch + prepare the app HTML once per (gateway, entryPath).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSrcDoc(null);
|
||||
setLoadError('');
|
||||
(async () => {
|
||||
try {
|
||||
const html = await fetchSpaceFileContent(spaceId, entryPath);
|
||||
const html = await gateway.fetchContent(entryPath);
|
||||
if (cancelled) return;
|
||||
setSrcDoc(injectCsp(rewriteRelativeAssets(html, spaceId, appName)));
|
||||
setSrcDoc(injectCsp(rewriteRelativeAssets(html, appName, gateway.rawUrl)));
|
||||
} catch {
|
||||
if (!cancelled) setLoadError('アプリの読み込みに失敗しました');
|
||||
if (!cancelled) setLoadError(t('appRunner.loadError'));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [spaceId, entryPath, appName]);
|
||||
}, [gateway, entryPath, appName]);
|
||||
|
||||
// Ask the user before a non-allowlisted write. Returns a promise that
|
||||
// resolves true (approved) / false (denied).
|
||||
@@ -147,7 +167,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
switch (req.type) {
|
||||
case 'readFile': {
|
||||
const path = resolveAppPath(appName, req.path);
|
||||
const content = await fetchSpaceFileContent(spaceId, path);
|
||||
const content = await gateway.fetchContent(path);
|
||||
return { id, ok: true, data: { path, content } };
|
||||
}
|
||||
case 'listFiles': {
|
||||
@@ -157,27 +177,33 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
if (typeof dirRaw === 'string' && dirRaw.trim() !== '') {
|
||||
dir = resolveAppPath(appName, dirRaw);
|
||||
}
|
||||
const r = await fetchSpaceFiles(spaceId, dir);
|
||||
const r = await gateway.listFiles(dir);
|
||||
return { id, ok: true, data: { dir, entries: r.entries } };
|
||||
}
|
||||
case 'writeFile': {
|
||||
// read-only gateway(公開共有)には writeFile が無い → 書き込み不可。
|
||||
if (!gateway.writeFile) return { id, ok: false, error: 'read-only' };
|
||||
const path = resolveAppPath(appName, req.path);
|
||||
if (typeof req.content !== 'string') {
|
||||
return { id, ok: false, error: 'content must be a string' };
|
||||
}
|
||||
if (!isWriteWithoutConfirm(appName, path)) {
|
||||
if (shouldConfirmWrite(appName, path, autoApproveWrites)) {
|
||||
const approved = await requestWriteConfirm(path);
|
||||
if (!approved) return { id, ok: false, error: 'write denied by user' };
|
||||
}
|
||||
const res = await writeSpaceFile(spaceId, path, { content: req.content });
|
||||
const res = await gateway.writeFile(path, req.content);
|
||||
return { id, ok: true, data: res };
|
||||
}
|
||||
case 'deleteFile': {
|
||||
// read-only gateway(公開共有)には deleteFile が無い → 削除不可。
|
||||
if (!gateway.deleteFile) return { id, ok: false, error: 'read-only' };
|
||||
const path = resolveAppPath(appName, req.path);
|
||||
// delete always confirms, regardless of directory.
|
||||
const approved = await requestWriteConfirm(path);
|
||||
if (!approved) return { id, ok: false, error: 'delete denied by user' };
|
||||
const res = await deleteSpaceFiles(spaceId, [path]);
|
||||
// delete always confirms, unless autoApproveWrites bypasses it.
|
||||
if (!autoApproveWrites) {
|
||||
const approved = await requestWriteConfirm(path);
|
||||
if (!approved) return { id, ok: false, error: 'delete denied by user' };
|
||||
}
|
||||
const res = await gateway.deleteFile(path);
|
||||
return { id, ok: true, data: res };
|
||||
}
|
||||
default:
|
||||
@@ -186,7 +212,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
} catch (e) {
|
||||
return { id, ok: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}, [spaceId, appName, requestWriteConfirm]);
|
||||
}, [gateway, appName, requestWriteConfirm, autoApproveWrites]);
|
||||
|
||||
// postMessage listener — only trusts messages from OUR iframe's window.
|
||||
useEffect(() => {
|
||||
@@ -214,15 +240,21 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
data-testid="app-runner"
|
||||
className="fixed inset-0 z-50 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-label={`ワークスペース・アプリ ${title}`}
|
||||
aria-label={t('appRunner.dialogLabel', { title })}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-hairline px-4 py-2">
|
||||
<span className="text-sm font-medium text-slate-800 dark:text-slate-100">{title}</span>
|
||||
<span
|
||||
className="rounded bg-surface px-2 py-0.5 text-2xs text-slate-500"
|
||||
title="このアプリはワークスペースのファイルにアクセスします(あなたの権限の範囲内)"
|
||||
title={
|
||||
writable
|
||||
? t('appRunner.accessTitle.writable')
|
||||
: t('appRunner.accessTitle.readonly')
|
||||
}
|
||||
>
|
||||
このアプリはワークスペースのファイルにアクセスします
|
||||
{writable
|
||||
? t('appRunner.accessLabel.writable')
|
||||
: t('appRunner.accessLabel.readonly')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
@@ -231,7 +263,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
onClick={onClose}
|
||||
className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
>
|
||||
閉じる
|
||||
{t('appRunner.close')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -258,10 +290,15 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
data-testid="app-write-confirm"
|
||||
className="w-[min(28rem,90vw)] rounded-lg border border-hairline bg-canvas p-4 shadow-xl"
|
||||
role="alertdialog"
|
||||
aria-label="書き込み確認"
|
||||
aria-label={t('appRunner.writeConfirm.label')}
|
||||
>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-200">
|
||||
アプリ「{title}」が <code className="rounded bg-surface px-1">{confirm.path}</code> に書き込もうとしています。許可しますか?
|
||||
<Trans
|
||||
i18nKey="appRunner.writeConfirm.body"
|
||||
t={t}
|
||||
values={{ title, path: confirm.path }}
|
||||
components={{ path: <code className="rounded bg-surface px-1" /> }}
|
||||
/>
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
@@ -270,7 +307,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
onClick={() => handleConfirm(false)}
|
||||
className="rounded px-3 py-1 text-sm text-slate-600 hover:bg-surface"
|
||||
>
|
||||
拒否
|
||||
{t('appRunner.writeConfirm.deny')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -278,7 +315,7 @@ export function AppRunner({ spaceId, appName, entryPath, onClose }: AppRunnerPro
|
||||
onClick={() => handleConfirm(true)}
|
||||
className="rounded bg-[var(--brand-primary)] px-3 py-1 text-sm text-white hover:opacity-90"
|
||||
>
|
||||
許可
|
||||
{t('appRunner.writeConfirm.allow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* 本コンポーネントはワークスペース統合(タスクページ廃止に向けた操作感寄せ)の一部。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SPLIT_MIN_LEFT = 320; // チャットの最小幅(px)
|
||||
export const SPLIT_MIN_RIGHT = 360; // 詳細の最小幅(px)
|
||||
@@ -67,6 +68,7 @@ interface ChatDetailSplitProps {
|
||||
}
|
||||
|
||||
export function ChatDetailSplit({ left, right, rightVisible, storageKey }: ChatDetailSplitProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const leftPaneRef = useRef<HTMLDivElement>(null);
|
||||
const draggingRef = useRef(false);
|
||||
@@ -172,7 +174,7 @@ export function ChatDetailSplit({ left, right, rightVisible, storageKey }: ChatD
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="チャットと詳細の幅を調整"
|
||||
aria-label={t('chatDetailSplit.resizeLabel')}
|
||||
data-testid="chat-detail-resize"
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={handleReset}
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchCrossSpaceCalendarMonth,
|
||||
fetchSpaceCalendarDay,
|
||||
type CrossCalendarSpace,
|
||||
type CalendarEvent,
|
||||
} from '../../api';
|
||||
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
|
||||
import { localToday, localTzOffset } from '../../lib/localDate';
|
||||
import {
|
||||
WEEKDAYS,
|
||||
localMonth,
|
||||
shiftMonth,
|
||||
monthGridDays,
|
||||
splitWeeks,
|
||||
fmtRange,
|
||||
fmtTimeBadge,
|
||||
layoutWeekBars,
|
||||
} from '../../lib/calendar';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { SwipeableTabs } from '../mobile/SwipeableTabs';
|
||||
|
||||
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
|
||||
|
||||
/** 'YYYY-MM' of the viewer's local today. */
|
||||
function localMonth(): string {
|
||||
return localToday().slice(0, 7);
|
||||
}
|
||||
/** month ± delta, as 'YYYY-MM' (calendar arithmetic on the 1st via UTC). */
|
||||
function shiftMonth(month: string, delta: number): string {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
/** All 'YYYY-MM-DD' cells for the month grid, padded to full weeks (Sun-start). */
|
||||
function monthGridDays(month: string): string[] {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const first = new Date(Date.UTC(y, m - 1, 1));
|
||||
const start = shiftDay(first.toISOString().slice(0, 10), -first.getUTCDay());
|
||||
const days: string[] = [];
|
||||
for (let i = 0; i < 42; i++) days.push(shiftDay(start, i));
|
||||
return days;
|
||||
}
|
||||
|
||||
interface CrossSpaceCalendarProps {
|
||||
/** open a space's detail (switches to the Spaces page). */
|
||||
onOpenSpace: (spaceId: string) => void;
|
||||
@@ -39,6 +29,7 @@ interface CrossSpaceCalendarProps {
|
||||
}
|
||||
|
||||
export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalendarProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const tzOffset = localTzOffset();
|
||||
const isMobile = useIsMobile();
|
||||
const [month, setMonth] = useState(localMonth());
|
||||
@@ -51,8 +42,10 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
|
||||
const today = localToday();
|
||||
const days = useMemo(() => monthGridDays(month), [month]);
|
||||
const weeks = useMemo(() => splitWeeks(days), [days]);
|
||||
const counts = monthQuery.data?.days ?? {};
|
||||
const spaces = monthQuery.data?.spaces ?? [];
|
||||
const events = useMemo(() => monthQuery.data?.events ?? [], [monthQuery.data]);
|
||||
const spaceById = useMemo(() => {
|
||||
const m = new Map<string, CrossCalendarSpace>();
|
||||
for (const s of spaces) m.set(s.id, s);
|
||||
@@ -61,7 +54,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
|
||||
const monthLabel = (() => {
|
||||
const [y, m] = month.split('-');
|
||||
return `${y}年${Number(m)}月`;
|
||||
return t('calendar.monthLabel', { year: y, month: Number(m) });
|
||||
})();
|
||||
|
||||
const grid = (
|
||||
@@ -73,7 +66,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
data-testid="cross-cal-prev"
|
||||
onClick={() => setMonth(m => shiftMonth(m, -1))}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
aria-label="前の月"
|
||||
aria-label={t('calendar.prevMonth')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M10 4l-4 4 4 4" /></svg>
|
||||
</button>
|
||||
@@ -83,7 +76,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
data-testid="cross-cal-next"
|
||||
onClick={() => setMonth(m => shiftMonth(m, 1))}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
aria-label="次の月"
|
||||
aria-label={t('calendar.nextMonth')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4l4 4-4 4" /></svg>
|
||||
</button>
|
||||
@@ -96,59 +89,103 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* day cells: one color dot per space with activity that day */}
|
||||
<div data-testid="cross-cal-grid" className="grid grid-cols-7 gap-1">
|
||||
{days.map(d => {
|
||||
const inMonth = d.slice(0, 7) === month;
|
||||
const bySpace = counts[d];
|
||||
const activeSpaceIds = bySpace
|
||||
? Object.keys(bySpace).filter(sid => (bySpace[sid]!.taskCount + bySpace[sid]!.eventCount) > 0)
|
||||
: [];
|
||||
const isToday = d === today;
|
||||
const isSelected = d === selectedDate;
|
||||
{/* day cells(タスクはスペース色のドット)+ 複数日予定の横棒(週ごと・スペース色) */}
|
||||
<div data-testid="cross-cal-grid" className="flex flex-col gap-1">
|
||||
{weeks.map((week, wi) => {
|
||||
const bars = layoutWeekBars(week, events);
|
||||
const laneCount = bars.reduce((m, b) => Math.max(m, b.lane + 1), 0);
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
data-testid={`cross-cal-day-${d}`}
|
||||
onClick={() => setSelectedDate(d)}
|
||||
className={`flex min-h-[3.25rem] flex-col items-stretch gap-1 rounded-md border p-1 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-[var(--brand-primary)] bg-surface'
|
||||
: 'border-hairline hover:bg-surface'
|
||||
} ${inMonth ? '' : 'opacity-40'}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[11px] font-semibold ${
|
||||
isToday
|
||||
? 'inline-flex h-5 w-5 items-center justify-center self-start rounded-full bg-[var(--brand-primary)] text-white'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{Number(d.slice(8, 10))}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-0.5">
|
||||
{activeSpaceIds.slice(0, 6).map(sid => {
|
||||
const sp = spaceById.get(sid);
|
||||
return (
|
||||
<div key={wi} className="grid grid-cols-7 gap-1">
|
||||
{week.map(d => {
|
||||
const inMonth = d.slice(0, 7) === month;
|
||||
const bySpace = counts[d];
|
||||
// ドットは「タスクのある」スペースだけ。予定は下の横棒で表す。
|
||||
const taskSpaceIds = bySpace
|
||||
? Object.keys(bySpace).filter(sid => bySpace[sid]!.taskCount > 0)
|
||||
: [];
|
||||
const isToday = d === today;
|
||||
const isSelected = d === selectedDate;
|
||||
return (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
data-testid={`cross-cal-day-${d}`}
|
||||
onClick={() => setSelectedDate(d)}
|
||||
className={`flex min-h-[2.5rem] flex-col items-stretch gap-0.5 rounded-md border p-1 text-left transition-colors ${
|
||||
isSelected
|
||||
? 'border-[var(--brand-primary)] bg-surface'
|
||||
: 'border-hairline hover:bg-surface'
|
||||
} ${inMonth ? '' : 'opacity-40'}`}
|
||||
>
|
||||
<span
|
||||
key={sid}
|
||||
data-testid={`cross-cal-dot-${sid}`}
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: sp?.color ?? 'var(--brand-primary)' }}
|
||||
title={sp?.name ?? sid}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{activeSpaceIds.length > 6 && (
|
||||
<span className="text-[8px] font-bold text-slate-400">+{activeSpaceIds.length - 6}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
className={`text-[11px] font-semibold ${
|
||||
isToday
|
||||
? 'inline-flex h-5 w-5 items-center justify-center self-start rounded-full bg-[var(--brand-primary)] text-white'
|
||||
: 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{Number(d.slice(8, 10))}
|
||||
</span>
|
||||
{taskSpaceIds.length > 0 && (
|
||||
<span className="flex flex-wrap gap-0.5">
|
||||
{taskSpaceIds.slice(0, 6).map(sid => {
|
||||
const sp = spaceById.get(sid);
|
||||
return (
|
||||
<span
|
||||
key={sid}
|
||||
data-testid={`cross-cal-dot-${sid}`}
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: sp?.color ?? 'var(--brand-primary)' }}
|
||||
title={t('crossCalendar.dotTitle', { name: sp?.name ?? sid, count: bySpace?.[sid]?.taskCount ?? 0 })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{taskSpaceIds.length > 6 && (
|
||||
<span className="text-[8px] font-bold text-slate-400">+{taskSpaceIds.length - 6}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{bars.length > 0 && (
|
||||
<div
|
||||
className="col-span-7 grid grid-cols-7 gap-x-1 gap-y-0.5 pb-0.5"
|
||||
style={{ gridTemplateRows: `repeat(${laneCount}, 1.05rem)` }}
|
||||
>
|
||||
{bars.map(b => {
|
||||
const sp = spaceById.get(b.ev.spaceId);
|
||||
const color = sp?.color ?? 'var(--brand-primary)';
|
||||
return (
|
||||
<button
|
||||
key={b.ev.id}
|
||||
type="button"
|
||||
data-testid={`cross-cal-bar-${b.ev.id}`}
|
||||
title={`${sp?.name ? t('crossCalendar.barTitlePrefix', { name: sp.name }) : ''}${t('crossCalendar.barTitle', { title: b.ev.title, range: fmtRange(b.ev) })}`}
|
||||
onClick={() => setSelectedDate(week[b.colStart - 1]!)}
|
||||
style={{
|
||||
gridColumn: `${b.colStart} / span ${b.colSpan}`,
|
||||
gridRow: b.lane + 1,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 22%, transparent)`,
|
||||
borderLeft: `2px solid ${color}`,
|
||||
}}
|
||||
className={`flex items-center overflow-hidden whitespace-nowrap px-1 text-[9px] font-semibold leading-none text-slate-700 transition-opacity hover:opacity-80 dark:text-slate-100 ${
|
||||
b.continuesLeft ? 'rounded-l-none' : 'rounded-l'
|
||||
} ${b.continuesRight ? 'rounded-r-none' : 'rounded-r'}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{b.continuesLeft ? '◀ ' : b.ev.time ? `${b.ev.time} ` : ''}{b.ev.title}{b.continuesRight ? ' ▶' : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">カレンダーの取得に失敗しました。</p>}
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">{t('calendar.fetchError')}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -157,6 +194,7 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
date={selectedDate}
|
||||
tzOffset={tzOffset}
|
||||
counts={counts[selectedDate] ?? {}}
|
||||
events={events}
|
||||
spaces={spaces}
|
||||
onOpenSpace={onOpenSpace}
|
||||
onOpenTask={onOpenTask}
|
||||
@@ -164,25 +202,25 @@ export function CrossSpaceCalendar({ onOpenSpace, onOpenTask }: CrossSpaceCalend
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
|
||||
日付を選ぶと、その日の各ワークスペースのタスク・予定が表示されます。
|
||||
{t('crossCalendar.emptyHint')}
|
||||
</div>
|
||||
);
|
||||
|
||||
// モバイル: 月の左右スワイプで月送り。日を選ぶと下にオーバーレイで日詳細を出す。
|
||||
// モバイル: 上に月グリッド(左右スワイプで月送り)、下に日詳細の上下 2 分割。
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div data-testid="cross-calendar" className="flex h-full flex-col overflow-hidden">
|
||||
<SwipeableTabs
|
||||
tabs={[shiftMonth(month, -1), month, shiftMonth(month, 1)]}
|
||||
activeTab={month}
|
||||
onTabChange={(m) => setMonth(m)}
|
||||
renderTab={(m) => (m === month ? <div className="p-3 overflow-y-auto h-full">{grid}</div> : <div className="h-full" />)}
|
||||
/>
|
||||
{selectedDate && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-canvas">
|
||||
{panel}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<SwipeableTabs
|
||||
tabs={[shiftMonth(month, -1), month, shiftMonth(month, 1)]}
|
||||
activeTab={month}
|
||||
onTabChange={(m) => setMonth(m)}
|
||||
renderTab={(m) => (m === month ? <div className="p-3 overflow-y-auto h-full">{grid}</div> : <div className="h-full" />)}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="cross-cal-mobile-detail" className="h-[45%] min-h-0 shrink-0 overflow-hidden border-t border-hairline">
|
||||
{panel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,6 +238,7 @@ function CrossDayPanel({
|
||||
date,
|
||||
tzOffset,
|
||||
counts,
|
||||
events,
|
||||
spaces,
|
||||
onOpenSpace,
|
||||
onOpenTask,
|
||||
@@ -209,19 +248,33 @@ function CrossDayPanel({
|
||||
tzOffset: number;
|
||||
/** {[spaceId]: {taskCount,eventCount}} for this day (from the month aggregate). */
|
||||
counts: Record<string, { taskCount: number; eventCount: number }>;
|
||||
/** その月の全イベント(spaceId 付き)。月集計のクリップ外の日でもバーから補完する。 */
|
||||
events: CalendarEvent[];
|
||||
spaces: CrossCalendarSpace[];
|
||||
onOpenSpace: (spaceId: string) => void;
|
||||
onOpenTask: (taskId: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t: tr } = useTranslation('spaces');
|
||||
// 月集計 counts は当月内にクリップされるため、月境界をまたぐ予定の隣月側の日を
|
||||
// 選ぶと活動なし扱いになってしまう。選択日に重なる予定の spaceId を直接補完する。
|
||||
const eventSpaceIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const ev of events) {
|
||||
const end = ev.endDate && ev.endDate > ev.date ? ev.endDate : ev.date;
|
||||
if (ev.date <= date && date <= end) ids.add(ev.spaceId);
|
||||
}
|
||||
return ids;
|
||||
}, [events, date]);
|
||||
|
||||
// Only fetch the per-space day detail for spaces that have activity on this
|
||||
// day — avoids a second cross endpoint and avoids N empty fetches.
|
||||
const activeSpaces = useMemo(
|
||||
() => spaces.filter(s => {
|
||||
const c = counts[s.id];
|
||||
return c && (c.taskCount + c.eventCount) > 0;
|
||||
return (c && (c.taskCount + c.eventCount) > 0) || eventSpaceIds.has(s.id);
|
||||
}),
|
||||
[spaces, counts],
|
||||
[spaces, counts, eventSpaceIds],
|
||||
);
|
||||
|
||||
const dayQueries = useQueries({
|
||||
@@ -240,7 +293,7 @@ function CrossDayPanel({
|
||||
data-testid="cross-cal-day-close"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 hover:bg-surface hover:text-slate-700"
|
||||
aria-label="閉じる"
|
||||
aria-label={tr('crossCalendar.close')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
</button>
|
||||
@@ -248,7 +301,7 @@ function CrossDayPanel({
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-4">
|
||||
{activeSpaces.length === 0 && (
|
||||
<p className="text-xs text-slate-400">この日に活動のあるワークスペースはありません。</p>
|
||||
<p className="text-xs text-slate-400">{tr('crossCalendar.noActiveSpaces')}</p>
|
||||
)}
|
||||
{activeSpaces.map((sp, i) => {
|
||||
const day = dayQueries[i]?.data;
|
||||
@@ -274,7 +327,7 @@ function CrossDayPanel({
|
||||
onClick={() => onOpenTask(t.id)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-left transition-colors hover:bg-surface"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || `タスク #${t.id}`}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || tr('crossCalendar.taskFallback', { id: t.id })}</span>
|
||||
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{t.status ?? t.state}</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -285,17 +338,20 @@ function CrossDayPanel({
|
||||
{/* 予定 */}
|
||||
{day && day.events.length > 0 && (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{day.events.map(ev => (
|
||||
{day.events.map((ev: CalendarEvent) => (
|
||||
<li
|
||||
key={ev.id}
|
||||
data-testid={`cross-cal-event-${ev.id}`}
|
||||
className="flex items-start gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5"
|
||||
>
|
||||
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||
{ev.time ?? '終日'}
|
||||
{fmtTimeBadge(ev)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
|
||||
{ev.endDate && ev.endDate > ev.date && (
|
||||
<div className="text-[10px] font-medium text-amber-700 dark:text-amber-300">🗓 {fmtRange(ev)}</div>
|
||||
)}
|
||||
{ev.description && <div className="truncate text-[11px] text-slate-500">{ev.description}</div>}
|
||||
</div>
|
||||
</li>
|
||||
@@ -304,7 +360,7 @@ function CrossDayPanel({
|
||||
)}
|
||||
|
||||
{day && day.tasks.length === 0 && day.events.length === 0 && (
|
||||
<p className="text-xs text-slate-400">表示できる項目がありません。</p>
|
||||
<p className="text-xs text-slate-400">{tr('crossCalendar.noItems')}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -11,18 +11,13 @@
|
||||
* spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
fetchInvitePreview,
|
||||
acceptSpaceInvite,
|
||||
type InvitePreview,
|
||||
type SpaceInviteRole,
|
||||
} from '../../api';
|
||||
|
||||
const ROLE_LABEL: Record<SpaceInviteRole, string> = {
|
||||
editor: '編集者',
|
||||
viewer: '閲覧者',
|
||||
};
|
||||
|
||||
type State =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'unauthorized' }
|
||||
@@ -40,6 +35,7 @@ function Shell({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export function JoinSpace({ token }: { token: string }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [state, setState] = useState<State>({ kind: 'loading' });
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -83,16 +79,16 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
const returnTo = encodeURIComponent(window.location.pathname);
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-2 text-base font-semibold text-slate-900">ワークスペースへの招待</h1>
|
||||
<h1 className="mb-2 text-base font-semibold text-slate-900">{t('joinSpace.title')}</h1>
|
||||
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
|
||||
参加するにはログインが必要です。ログイン後、この招待ページに戻ります。
|
||||
{t('joinSpace.unauthorized.body')}
|
||||
</p>
|
||||
<a
|
||||
href={`/auth/login?returnTo=${returnTo}`}
|
||||
data-testid="join-space-login"
|
||||
className="inline-block rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-fg hover:bg-accent-deep"
|
||||
>
|
||||
ログインして参加
|
||||
{t('joinSpace.unauthorized.login')}
|
||||
</a>
|
||||
</Shell>
|
||||
);
|
||||
@@ -101,12 +97,12 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
if (state.kind === 'invalid') {
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-2 text-base font-semibold text-slate-900">リンクが無効です</h1>
|
||||
<h1 className="mb-2 text-base font-semibold text-slate-900">{t('joinSpace.invalid.title')}</h1>
|
||||
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
|
||||
この招待リンクは期限切れか、取り消された可能性があります。共有元にもう一度リンクの発行を依頼してください。
|
||||
{t('joinSpace.invalid.body')}
|
||||
</p>
|
||||
<a href="/ui/" className="text-[13px] text-slate-600 underline hover:text-slate-900">
|
||||
ホームへ
|
||||
{t('joinSpace.invalid.home')}
|
||||
</a>
|
||||
</Shell>
|
||||
);
|
||||
@@ -115,11 +111,11 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
// ok
|
||||
return (
|
||||
<Shell>
|
||||
<h1 className="mb-1 text-base font-semibold text-slate-900">ワークスペースへの招待</h1>
|
||||
<h1 className="mb-1 text-base font-semibold text-slate-900">{t('joinSpace.title')}</h1>
|
||||
<p className="mb-5 text-[13px] leading-relaxed text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{state.preview.spaceTitle}</span> に
|
||||
<span className="font-semibold text-slate-900">「{ROLE_LABEL[state.preview.role]}」</span>
|
||||
として参加します。
|
||||
<span className="font-semibold text-slate-900">{state.preview.spaceTitle}</span>{t('joinSpace.ok.joinAs.lead')}
|
||||
<span className="font-semibold text-slate-900">{t('joinSpace.ok.joinAs.roleQuoted', { role: t(`joinSpace.role.${state.preview.role}`) })}</span>
|
||||
{t('joinSpace.ok.joinAs.trail')}
|
||||
</p>
|
||||
{error && <div className="mb-3 text-[13px] text-red-600">{error}</div>}
|
||||
<div className="flex justify-center gap-2">
|
||||
@@ -127,7 +123,7 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
href="/ui/"
|
||||
className="rounded-md border border-hairline px-4 py-2 text-sm text-slate-700 hover:bg-surface"
|
||||
>
|
||||
やめる
|
||||
{t('joinSpace.ok.cancel')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
@@ -136,7 +132,7 @@ export function JoinSpace({ token }: { token: string }) {
|
||||
disabled={joining}
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-fg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{joining ? '参加中…' : '参加する'}
|
||||
{joining ? t('joinSpace.ok.joining') : t('joinSpace.ok.join')}
|
||||
</button>
|
||||
</div>
|
||||
</Shell>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchSpaceFiles, fetchSpaceFileContent } from '../../api';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceFiles,
|
||||
fetchSpaceFileContent,
|
||||
getAppShareLink,
|
||||
createAppShareLink,
|
||||
revokeAppShareLink,
|
||||
} from '../../api';
|
||||
import { deriveAppList, type WorkspaceApp } from './app-bridge';
|
||||
import { AppRunner } from './AppRunner';
|
||||
import { createSpaceGateway } from './app-file-gateway';
|
||||
import { buildAppShareDisplayUrl } from './appShareUrl';
|
||||
|
||||
/**
|
||||
* SpaceApps — the workspace "アプリ" tab.
|
||||
@@ -17,7 +26,8 @@ import { AppRunner } from './AppRunner';
|
||||
* NETWORK / SECURITY: the apps themselves run under AppRunner's opaque-origin
|
||||
* sandbox with `connect-src 'none'` — this tab only discovers + launches them.
|
||||
*/
|
||||
export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
export function SpaceApps({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [appToRun, setAppToRun] = useState<WorkspaceApp | null>(null);
|
||||
|
||||
const appsQuery = useQuery({
|
||||
@@ -32,15 +42,15 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
<div data-testid="space-apps" className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider text-slate-500">
|
||||
ワークスペース・アプリ
|
||||
{t('apps.heading')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-apps-refresh"
|
||||
onClick={() => void appsQuery.refetch()}
|
||||
disabled={appsQuery.isFetching}
|
||||
title="再読み込み"
|
||||
aria-label="再読み込み"
|
||||
title={t('apps.refresh')}
|
||||
aria-label={t('apps.refresh')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
<svg className={`h-3.5 w-3.5 ${appsQuery.isFetching ? 'animate-spin' : ''}`} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -51,7 +61,7 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
</div>
|
||||
|
||||
{appsQuery.isError && (
|
||||
<p className="text-xs text-red-600">アプリ一覧の取得に失敗しました。</p>
|
||||
<p className="text-xs text-red-600">{t('apps.fetchError')}</p>
|
||||
)}
|
||||
|
||||
{!appsQuery.isLoading && !appsQuery.isError && apps.length === 0 && (
|
||||
@@ -59,12 +69,16 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
data-testid="space-apps-empty"
|
||||
className="rounded-lg border border-dashed border-hairline bg-surface p-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
<p className="font-medium text-slate-600">まだアプリがありません</p>
|
||||
<p className="font-medium text-slate-600">{t('apps.empty.title')}</p>
|
||||
<p className="mt-1.5 leading-relaxed">
|
||||
エージェントに「ワークスペース・アプリを作って」と頼むと、ここに表示されます。
|
||||
{t('apps.empty.body')}
|
||||
</p>
|
||||
<p className="mt-1.5 text-2xs text-slate-400">
|
||||
アプリはワークスペースの <code className="rounded bg-canvas px-1 py-0.5">apps/</code> フォルダ(<code className="rounded bg-canvas px-1 py-0.5">apps/{名前}/index.html</code>)に置かれます。
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="apps.empty.location"
|
||||
components={{ code: <code className="rounded bg-canvas px-1 py-0.5" /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -106,8 +120,10 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" stroke="none">
|
||||
<path d="M5 3.5v9l7-4.5z" />
|
||||
</svg>
|
||||
開く
|
||||
{t('apps.open')}
|
||||
</button>
|
||||
|
||||
{canManage && <AppShareControls spaceId={spaceId} appName={app.name} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -115,7 +131,7 @@ export function SpaceApps({ spaceId }: { spaceId: string }) {
|
||||
|
||||
{appToRun && (
|
||||
<AppRunner
|
||||
spaceId={spaceId}
|
||||
gateway={createSpaceGateway(spaceId)}
|
||||
appName={appToRun.name}
|
||||
entryPath={appToRun.entryPath}
|
||||
onClose={() => setAppToRun(null)}
|
||||
@@ -182,3 +198,138 @@ async function loadWorkspaceApps(spaceId: string): Promise<WorkspaceApp[]> {
|
||||
name => indexMap.get(name)?.manifest ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AppShareControls — 1 アプリの公開共有リンク管理(canManage のみ表示)。
|
||||
*
|
||||
* 現在のリンク状態を取得し、未発行なら「リンクを作成」、発行済みなら公開 URL の表示・
|
||||
* コピー・失効を提供する。発行/失効は react-query mutation。公開リンクは read-only・
|
||||
* ログイン不要・apps/{app}/+output/ に封じ込められる旨を注意書きで明示する。
|
||||
*/
|
||||
function AppShareControls({ spaceId, appName }: { spaceId: string; appName: string }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const queryKey = ['app-share-link', spaceId, appName];
|
||||
|
||||
const linkQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => getAppShareLink(spaceId, appName),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createAppShareLink(spaceId, appName),
|
||||
onSuccess: (data) => {
|
||||
qc.setQueryData(queryKey, { token: data.token, shareUrl: data.shareUrl, revokedAt: null });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: () => revokeAppShareLink(spaceId, appName),
|
||||
onSuccess: () => {
|
||||
qc.setQueryData(queryKey, { token: null, revokedAt: new Date().toISOString() });
|
||||
setCopied(false);
|
||||
},
|
||||
});
|
||||
|
||||
const link = linkQuery.data;
|
||||
const shareUrl = link?.token && link.shareUrl ? link.shareUrl : null;
|
||||
const displayUrl = shareUrl ? buildAppShareDisplayUrl(window.location.origin, shareUrl) : null;
|
||||
|
||||
const copy = async () => {
|
||||
if (!displayUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayUrl);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// clipboard 不可(権限なし等)。表示済み URL から手動コピーできるので無視。
|
||||
}
|
||||
};
|
||||
|
||||
const onRevoke = () => {
|
||||
if (window.confirm(t('appShare.revokeConfirm', { appName }))) {
|
||||
revokeMut.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const busy = createMut.isPending || revokeMut.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`space-app-share-${appName}`}
|
||||
className="mt-1 border-t border-hairline pt-2"
|
||||
>
|
||||
{linkQuery.isLoading ? (
|
||||
<p className="text-2xs text-slate-400">{t('appShare.checking')}</p>
|
||||
) : displayUrl ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-2xs font-semibold text-emerald-600 dark:text-emerald-400"
|
||||
data-testid={`space-app-share-badge-${appName}`}
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
|
||||
</svg>
|
||||
{t('appShare.issued')}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={displayUrl}
|
||||
data-testid={`space-app-share-url-${appName}`}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="w-full truncate rounded border border-hairline bg-canvas px-2 py-1 font-mono text-2xs text-slate-600"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-share-copy-${appName}`}
|
||||
onClick={() => void copy()}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface"
|
||||
>
|
||||
{copied ? t('appShare.copied') : t('appShare.copyUrl')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-share-revoke-${appName}`}
|
||||
onClick={onRevoke}
|
||||
disabled={busy}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-red-600 transition-colors hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-950/30"
|
||||
>
|
||||
{t('appShare.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-2xs leading-relaxed text-amber-600">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="appShare.warning"
|
||||
components={{ code: <code className="rounded bg-canvas px-1" />, strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-share-create-${appName}`}
|
||||
onClick={() => createMut.mutate()}
|
||||
disabled={busy}
|
||||
className="inline-flex h-7 items-center gap-1 self-start rounded-md border border-hairline bg-canvas px-2 text-2xs font-semibold text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.5 9.5l-2 2a2.5 2.5 0 01-3.5-3.5l2-2M9.5 6.5l2-2a2.5 2.5 0 013.5 3.5l-2 2M5.5 10.5l5-5" />
|
||||
</svg>
|
||||
{createMut.isPending ? t('appShare.creating') : t('appShare.create')}
|
||||
</button>
|
||||
{(createMut.isError || revokeMut.isError) && (
|
||||
<p className="text-2xs text-red-600">{t('appShare.opError')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* ときだけ管理コントロールを出す。判定できない場合でも 403 はトーストで処理。
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
listBrowserSessionProfiles, deleteBrowserSessionProfile, testBrowserSessionProfile,
|
||||
@@ -28,14 +29,6 @@ import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
const STATUS_LABEL: Record<BrowserSessionProfile['status'], string> = {
|
||||
pending: '保存待ち',
|
||||
active: '有効',
|
||||
expired: '期限切れ',
|
||||
revoked: '失効',
|
||||
error: 'エラー',
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<BrowserSessionProfile['status'], string> = {
|
||||
pending: 'bg-slate-200 text-slate-700',
|
||||
active: 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
||||
@@ -45,9 +38,10 @@ const STATUS_CLASS: Record<BrowserSessionProfile['status'], string> = {
|
||||
};
|
||||
|
||||
function StatusPill({ status }: { status: BrowserSessionProfile['status'] }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded px-2 py-0.5 text-2xs font-medium ${STATUS_CLASS[status]}`}>
|
||||
{STATUS_LABEL[status]}
|
||||
{t(`browser.status.${status}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +51,7 @@ function errMsg(e: unknown): string {
|
||||
}
|
||||
|
||||
export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -81,12 +76,12 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
const delSess = useMutation({
|
||||
mutationFn: (id: number) => deleteBrowserSessionProfile(id, spaceId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }),
|
||||
onError: (e) => showToast?.(`セッションの削除に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('browser.toast.sessionDeleteFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
const testSess = useMutation({
|
||||
mutationFn: (id: number) => testBrowserSessionProfile(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-sessions', spaceId] }),
|
||||
onError: (e) => showToast?.(`セッションの検証に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('browser.toast.sessionTestFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
@@ -102,12 +97,12 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
const delMacro = useMutation({
|
||||
mutationFn: (name: string) => deleteFolderFile('browser-macros', name, spaceId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-macros', spaceId] }),
|
||||
onError: (e) => showToast?.(`削除に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('browser.toast.deleteFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
const delRecording = useMutation({
|
||||
mutationFn: (name: string) => deleteFolderFile('recordings', name, spaceId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['space-browser-recordings', spaceId] }),
|
||||
onError: (e) => showToast?.(`削除に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('browser.toast.deleteFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
// ファイル内容プレビュー(マクロ・録画共通)。
|
||||
@@ -117,7 +112,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
const content = await getFolderFile(subdir, name, spaceId);
|
||||
setPreview({ name, content });
|
||||
} catch (e) {
|
||||
showToast?.(`内容の取得に失敗しました: ${errMsg(e)}`, 'error');
|
||||
showToast?.(t('browser.toast.contentFetchFailed', { msg: errMsg(e) }), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +122,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
{/* ── セッション ── */}
|
||||
<section data-testid="space-browser-sessions">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-800">ブラウザセッション</h2>
|
||||
<h2 className="text-base font-semibold text-slate-800">{t('browser.sessions.heading')}</h2>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -135,22 +130,21 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
onClick={() => setAdding(true)}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-fg hover:bg-accent-deep"
|
||||
>
|
||||
セッションを追加
|
||||
{t('browser.sessions.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-slate-500">
|
||||
このワークスペースで共有するログイン済みブラウザセッション。各セッションは
|
||||
作成者の鍵で暗号化されるため、利用できるのは作成した本人だけです。
|
||||
{t('browser.sessions.intro')}
|
||||
</p>
|
||||
|
||||
{sessLoading && <div className="text-xs text-slate-500">読み込み中…</div>}
|
||||
{sessLoading && <div className="text-xs text-slate-500">{t('common:loading')}</div>}
|
||||
|
||||
<div className="divide-y divide-hairline rounded-md border border-hairline">
|
||||
{profiles.length === 0 && !sessLoading && (
|
||||
<div className="px-3 py-6 text-center text-xs text-slate-400">
|
||||
<div>このワークスペースにはまだブラウザセッションがありません。</div>
|
||||
{canManage && <div className="mt-1">「セッションを追加」からログインして保存してください。</div>}
|
||||
<div>{t('browser.sessions.empty')}</div>
|
||||
{canManage && <div className="mt-1">{t('browser.sessions.emptyHint')}</div>}
|
||||
</div>
|
||||
)}
|
||||
{profiles.map(p => {
|
||||
@@ -163,7 +157,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
<StatusPill status={p.status} />
|
||||
{!usable && (
|
||||
<span className="inline-flex items-center rounded bg-slate-200 px-2 py-0.5 text-2xs font-medium text-slate-600">
|
||||
作成者のみ利用可
|
||||
{t('browser.sessions.creatorOnly')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -171,7 +165,7 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
{p.lastError && <div className="truncate text-2xs text-rose-600">{p.lastError}</div>}
|
||||
{!usable && (
|
||||
<div className="text-2xs text-slate-400">
|
||||
このセッションは作成者の鍵で暗号化されています。閲覧はできますが、別のメンバーは復号・利用できません。
|
||||
{t('browser.sessions.creatorOnlyHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -183,16 +177,16 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
disabled={testSess.isPending}
|
||||
className="rounded px-2 py-1 text-xs text-slate-700 hover:bg-surface hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
検証
|
||||
{t('browser.sessions.test')}
|
||||
</button>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (confirm(`「${p.label}」を削除しますか?`)) delSess.mutate(p.id); }}
|
||||
onClick={() => { if (confirm(t('browser.deleteConfirm', { name: p.label }))) delSess.mutate(p.id); }}
|
||||
className="rounded px-2 py-1 text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-800 dark:hover:bg-rose-500/15 dark:hover:text-rose-300"
|
||||
>
|
||||
削除
|
||||
{t('common:delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -204,28 +198,28 @@ export function SpaceBrowserPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
|
||||
{/* ── マクロ ── */}
|
||||
<FolderSection
|
||||
title="ブラウザマクロ"
|
||||
title={t('browser.macros.title')}
|
||||
testid="space-browser-macros"
|
||||
subdir="browser-macros"
|
||||
query={macros}
|
||||
emptyText="このワークスペースにはまだブラウザマクロがありません。"
|
||||
hint="エージェントがブラウザ操作を記録すると、このワークスペースのマクロとして保存されます。"
|
||||
emptyText={t('browser.macros.empty')}
|
||||
hint={t('browser.macros.hint')}
|
||||
canManage={canManage}
|
||||
onView={(name) => openPreview('browser-macros', name)}
|
||||
onDelete={(name) => { if (confirm(`「${name}」を削除しますか?`)) delMacro.mutate(name); }}
|
||||
onDelete={(name) => { if (confirm(t('browser.deleteConfirm', { name }))) delMacro.mutate(name); }}
|
||||
/>
|
||||
|
||||
{/* ── 録画 ── */}
|
||||
<FolderSection
|
||||
title="録画"
|
||||
title={t('browser.recordings.title')}
|
||||
testid="space-browser-recordings"
|
||||
subdir="recordings"
|
||||
query={recordings}
|
||||
emptyText="このワークスペースにはまだ録画がありません。"
|
||||
hint="ブラウザ操作の記録(録画)がこのワークスペースのフォルダに保存されます。"
|
||||
emptyText={t('browser.recordings.empty')}
|
||||
hint={t('browser.recordings.hint')}
|
||||
canManage={canManage}
|
||||
onView={(name) => openPreview('recordings', name)}
|
||||
onDelete={(name) => { if (confirm(`「${name}」を削除しますか?`)) delRecording.mutate(name); }}
|
||||
onDelete={(name) => { if (confirm(t('browser.deleteConfirm', { name }))) delRecording.mutate(name); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -253,12 +247,13 @@ interface FolderSectionProps {
|
||||
}
|
||||
|
||||
function FolderSection({ title, testid, query, emptyText, hint, canManage, onView, onDelete }: FolderSectionProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const files = query.data ?? [];
|
||||
return (
|
||||
<section data-testid={testid}>
|
||||
<h2 className="mb-2 text-base font-semibold text-slate-800">{title}</h2>
|
||||
<p className="mb-3 text-xs text-slate-500">{hint}</p>
|
||||
{query.isLoading && <div className="text-xs text-slate-500">読み込み中…</div>}
|
||||
{query.isLoading && <div className="text-xs text-slate-500">{t('common:loading')}</div>}
|
||||
<div className="divide-y divide-hairline rounded-md border border-hairline">
|
||||
{files.length === 0 && !query.isLoading && (
|
||||
<div className="px-3 py-6 text-center text-xs text-slate-400">{emptyText}</div>
|
||||
@@ -279,7 +274,7 @@ function FolderSection({ title, testid, query, emptyText, hint, canManage, onVie
|
||||
onClick={() => onDelete(f.name)}
|
||||
className="ml-2 shrink-0 rounded px-2 py-1 text-xs text-rose-600 hover:bg-rose-50 hover:text-rose-800 dark:hover:bg-rose-500/15 dark:hover:text-rose-300"
|
||||
>
|
||||
削除
|
||||
{t('common:delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceCalendarMonth,
|
||||
@@ -8,60 +9,40 @@ import {
|
||||
deleteCalendarEvent,
|
||||
fetchSpaceFileContent,
|
||||
getSpaceFileRawUrl,
|
||||
getSpaceFileOfficePreviewUrl,
|
||||
getSpaceTrustedHtmlUrl,
|
||||
type CalendarEvent,
|
||||
} from '../../api';
|
||||
import { localToday, localTzOffset, shiftDay } from '../../lib/localDate';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
|
||||
import { localToday, localTzOffset } from '../../lib/localDate';
|
||||
import {
|
||||
WEEKDAYS,
|
||||
localMonth,
|
||||
shiftMonth,
|
||||
monthGridDays,
|
||||
splitWeeks,
|
||||
fmtRange,
|
||||
fmtTimeBadge,
|
||||
layoutWeekBars,
|
||||
} from '../../lib/calendar';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { FilePreview } from '../files/FilePreview';
|
||||
import type { OfficePreviewDescriptor } from '../files/FilePreview';
|
||||
import { FileTypeIcon } from '../files/FileTypeIcon';
|
||||
import { SwipeableTabs } from '../mobile/SwipeableTabs';
|
||||
|
||||
const WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
|
||||
|
||||
/** 'YYYY-MM' of the viewer's local today. */
|
||||
function localMonth(): string {
|
||||
return localToday().slice(0, 7);
|
||||
}
|
||||
/** month ± delta, as 'YYYY-MM' (calendar arithmetic on the 1st via UTC). */
|
||||
function shiftMonth(month: string, delta: number): string {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const d = new Date(Date.UTC(y, m - 1 + delta, 1));
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
/** All 'YYYY-MM-DD' cells for the month grid, padded to full weeks (Sun-start). */
|
||||
function monthGridDays(month: string): string[] {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
const first = new Date(Date.UTC(y, m - 1, 1));
|
||||
const start = shiftDay(first.toISOString().slice(0, 10), -first.getUTCDay());
|
||||
const days: string[] = [];
|
||||
// 6 weeks always covers any month layout (max 31 days + 6 lead).
|
||||
for (let i = 0; i < 42; i++) days.push(shiftDay(start, i));
|
||||
return days;
|
||||
}
|
||||
function fmtSize(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}MB`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}KB`;
|
||||
return `${n}B`;
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 'M/D'(月グリッドのバー・期間表示用)。 */
|
||||
function fmtMonthDay(d: string): string {
|
||||
return `${Number(d.slice(5, 7))}/${Number(d.slice(8, 10))}`;
|
||||
}
|
||||
/** 予定の期間を短く: 単日は 'M/D'、複数日は 'M/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 FILTER_DEFS: ReadonlyArray<{ key: keyof CalFilters; labelKey: string; icon: string }> = [
|
||||
{ key: 'tasks', labelKey: 'calendar.filter.tasks', icon: '💬' },
|
||||
{ key: 'files', labelKey: 'calendar.filter.files', icon: '📄' },
|
||||
{ key: 'events', labelKey: 'calendar.filter.events', icon: '📌' },
|
||||
];
|
||||
const FILTERS_STORAGE_KEY = 'spaceCalendarFilters';
|
||||
function loadFilters(): CalFilters {
|
||||
@@ -75,49 +56,6 @@ function loadFilters(): CalFilters {
|
||||
return { tasks: true, files: true, events: true };
|
||||
}
|
||||
|
||||
/** 1 週(7 日)にかかるイベントを、横棒の lane(重ならない行)に割り付ける。 */
|
||||
interface WeekBar {
|
||||
ev: CalendarEvent;
|
||||
colStart: number; // 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). */
|
||||
@@ -127,6 +65,7 @@ interface SpaceCalendarProps {
|
||||
}
|
||||
|
||||
export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const tzOffset = localTzOffset();
|
||||
const isMobile = useIsMobile();
|
||||
const [month, setMonth] = useState(localMonth());
|
||||
@@ -148,16 +87,12 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
|
||||
const today = localToday();
|
||||
const days = useMemo(() => monthGridDays(month), [month]);
|
||||
const weeks = useMemo(() => {
|
||||
const out: string[][] = [];
|
||||
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
|
||||
return out;
|
||||
}, [days]);
|
||||
const weeks = useMemo(() => splitWeeks(days), [days]);
|
||||
const counts = monthQuery.data?.days ?? {};
|
||||
const events = useMemo(() => monthQuery.data?.events ?? [], [monthQuery.data]);
|
||||
const monthLabel = (() => {
|
||||
const [y, m] = month.split('-');
|
||||
return `${y}年${Number(m)}月`;
|
||||
return t('calendar.monthLabel', { year: y, month: Number(m) });
|
||||
})();
|
||||
|
||||
const grid = (
|
||||
@@ -169,7 +104,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
data-testid="space-cal-prev"
|
||||
onClick={() => setMonth(m => shiftMonth(m, -1))}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
aria-label="前の月"
|
||||
aria-label={t('calendar.prevMonth')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M10 4l-4 4 4 4" /></svg>
|
||||
</button>
|
||||
@@ -179,7 +114,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
data-testid="space-cal-next"
|
||||
onClick={() => setMonth(m => shiftMonth(m, 1))}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
aria-label="次の月"
|
||||
aria-label={t('calendar.nextMonth')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round"><path d="M6 4l4 4-4 4" /></svg>
|
||||
</button>
|
||||
@@ -202,7 +137,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
: 'border-hairline bg-canvas text-slate-400 line-through'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden>{f.icon}</span>{f.label}
|
||||
<span aria-hidden>{f.icon}</span>{t(f.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -249,7 +184,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
{Number(d.slice(8, 10))}
|
||||
</span>
|
||||
{filters.tasks && c?.taskCount ? (
|
||||
<span className="self-start rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={`タスク ${c.taskCount} 件`}>
|
||||
<span className="self-start rounded bg-sky-100 px-1 text-[9px] font-bold text-sky-700 dark:bg-sky-500/20 dark:text-sky-300" title={t('calendar.taskCount', { count: c.taskCount })}>
|
||||
💬{c.taskCount}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -266,7 +201,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
key={b.ev.id}
|
||||
type="button"
|
||||
data-testid={`space-cal-bar-${b.ev.id}`}
|
||||
title={`${b.ev.title}(${fmtRange(b.ev)})`}
|
||||
title={t('calendar.barTitle', { title: b.ev.title, range: fmtRange(b.ev) })}
|
||||
onClick={() => setSelectedDate(week[b.colStart - 1]!)}
|
||||
style={{ gridColumn: `${b.colStart} / span ${b.colSpan}`, gridRow: b.lane + 1 }}
|
||||
className={`flex items-center overflow-hidden whitespace-nowrap bg-amber-100 px-1 text-[9px] font-semibold leading-none text-amber-800 transition-colors hover:bg-amber-200 dark:bg-amber-500/25 dark:text-amber-200 ${
|
||||
@@ -284,7 +219,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">カレンダーの取得に失敗しました。</p>}
|
||||
{monthQuery.isError && <p className="text-xs text-red-600">{t('calendar.fetchError')}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -301,7 +236,7 @@ export function SpaceCalendar({ spaceId, onOpenChat, canEdit }: SpaceCalendarPro
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
|
||||
日付を選ぶと、その日のタスク・変更ファイル・予定が表示されます。
|
||||
{t('calendar.emptyHint')}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -339,6 +274,7 @@ interface DayPanelPreview {
|
||||
imageSrc: string;
|
||||
markdownImageBaseUrl?: string;
|
||||
trustedHtmlUrl?: string;
|
||||
office?: OfficePreviewDescriptor;
|
||||
}
|
||||
|
||||
function DayPanel({
|
||||
@@ -360,6 +296,7 @@ function DayPanel({
|
||||
onOpenChat: (taskId: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const dayQuery = useQuery({
|
||||
queryKey: ['spaceCalendarDay', spaceId, date, tzOffset],
|
||||
@@ -376,6 +313,16 @@ function DayPanel({
|
||||
|
||||
const handlePreview = useCallback(async (filePath: string, name: string) => {
|
||||
try {
|
||||
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
|
||||
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
|
||||
setPreview({
|
||||
name,
|
||||
content: '',
|
||||
imageSrc: '',
|
||||
office: { kind, url: getSpaceFileOfficePreviewUrl(spaceId, filePath), downloadUrl: getSpaceFileRawUrl(spaceId, filePath) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
|
||||
const imageSrc = getSpaceFileRawUrl(spaceId, filePath);
|
||||
const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined;
|
||||
@@ -405,7 +352,7 @@ function DayPanel({
|
||||
data-testid="space-cal-day-close"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 hover:bg-surface hover:text-slate-700"
|
||||
aria-label="閉じる"
|
||||
aria-label={t('calendar.close')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||
</button>
|
||||
@@ -415,25 +362,25 @@ function DayPanel({
|
||||
{/* タスク */}
|
||||
{filters.tasks && (
|
||||
<section>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">タスク</h4>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.tasks')}</h4>
|
||||
{day && day.tasks.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{day.tasks.map(t => (
|
||||
<li key={t.id}>
|
||||
{day.tasks.map(task => (
|
||||
<li key={task.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-cal-task-${t.id}`}
|
||||
onClick={() => onOpenChat(t.id)}
|
||||
data-testid={`space-cal-task-${task.id}`}
|
||||
onClick={() => onOpenChat(task.id)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-left transition-colors hover:bg-surface"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{t.title || `タスク #${t.id}`}</span>
|
||||
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{t.status ?? t.state}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">{task.title || t('calendar.taskFallback', { id: task.id })}</span>
|
||||
<span className="shrink-0 rounded bg-surface px-1.5 py-0.5 text-[10px] font-medium text-slate-500">{task.status ?? task.state}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">この日のタスクはありません。</p>
|
||||
<p className="text-xs text-slate-400">{t('calendar.day.noTasks')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -441,7 +388,7 @@ function DayPanel({
|
||||
{/* 変更ファイル */}
|
||||
{filters.files && (
|
||||
<section>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">変更ファイル</h4>
|
||||
<h4 className="mb-1.5 text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.changedFiles')}</h4>
|
||||
{day && day.files.length > 0 ? (
|
||||
<ul className="space-y-1">
|
||||
{day.files.map(f => (
|
||||
@@ -462,7 +409,7 @@ function DayPanel({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">この日に変更されたファイルはありません。</p>
|
||||
<p className="text-xs text-slate-400">{t('calendar.day.noChangedFiles')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -471,7 +418,7 @@ function DayPanel({
|
||||
{filters.events && (
|
||||
<section>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<h4 className="text-2xs font-bold uppercase tracking-wider text-slate-500">予定</h4>
|
||||
<h4 className="text-2xs font-bold uppercase tracking-wider text-slate-500">{t('calendar.day.events')}</h4>
|
||||
{canEdit && !showAdd && !editing && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -479,7 +426,7 @@ function DayPanel({
|
||||
onClick={() => setShowAdd(true)}
|
||||
className="rounded-md border border-hairline bg-canvas px-2 py-0.5 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface hover:text-slate-900"
|
||||
>
|
||||
+ 予定を追加
|
||||
{t('calendar.day.addEvent')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -503,7 +450,7 @@ function DayPanel({
|
||||
className="flex items-start gap-2 rounded-md border border-hairline bg-canvas px-2 py-1.5"
|
||||
>
|
||||
<span className="shrink-0 rounded bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold text-amber-700 dark:bg-amber-500/20 dark:text-amber-300">
|
||||
{ev.time ?? '終日'}
|
||||
{fmtTimeBadge(ev)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-slate-700">{ev.title}</div>
|
||||
@@ -511,7 +458,7 @@ function DayPanel({
|
||||
<div className="text-[10px] font-medium text-amber-700 dark:text-amber-300">🗓 {fmtRange(ev)}</div>
|
||||
)}
|
||||
{ev.description && <div className="truncate text-[11px] text-slate-500">{ev.description}</div>}
|
||||
{ev.createdBy === 'agent' && <div className="text-[10px] text-slate-400">🤖 エージェント</div>}
|
||||
{ev.createdBy === 'agent' && <div className="text-[10px] text-slate-400">{t('calendar.day.agent')}</div>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -520,7 +467,7 @@ function DayPanel({
|
||||
data-testid={`space-cal-event-edit-${ev.id}`}
|
||||
onClick={() => { setShowAdd(false); setEditing(ev); }}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-surface hover:text-slate-700"
|
||||
aria-label="編集"
|
||||
aria-label={t('calendar.day.editEvent')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M11 2l3 3-8 8H3v-3z" /></svg>
|
||||
</button>
|
||||
@@ -528,12 +475,12 @@ function DayPanel({
|
||||
type="button"
|
||||
data-testid={`space-cal-event-delete-${ev.id}`}
|
||||
onClick={async () => {
|
||||
if (!window.confirm('この予定を削除しますか?')) return;
|
||||
if (!window.confirm(t('calendar.day.deleteEventConfirm'))) return;
|
||||
await deleteCalendarEvent(spaceId, ev.id);
|
||||
invalidate();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded text-slate-400 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
aria-label="削除"
|
||||
aria-label={t('common:delete')}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" /></svg>
|
||||
</button>
|
||||
@@ -543,7 +490,7 @@ function DayPanel({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
!showAdd && <p className="text-xs text-slate-400">予定はありません。</p>
|
||||
!showAdd && <p className="text-xs text-slate-400">{t('calendar.day.noEvents')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
@@ -556,6 +503,7 @@ function DayPanel({
|
||||
imageSrc={preview.imageSrc}
|
||||
markdownImageBaseUrl={preview.markdownImageBaseUrl}
|
||||
trustedHtmlUrl={preview.trustedHtmlUrl}
|
||||
office={preview.office}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -576,17 +524,25 @@ function EventForm({
|
||||
onCancel: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [title, setTitle] = useState(event?.title ?? '');
|
||||
const [date, setDate] = useState(event?.date ?? defaultDate);
|
||||
const [endDate, setEndDate] = useState(event?.endDate ?? '');
|
||||
const [time, setTime] = useState(event?.time ?? '');
|
||||
const [endTime, setEndTime] = useState(event?.endTime ?? '');
|
||||
const [description, setDescription] = useState(event?.description ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!title.trim()) { setError('タイトルを入力してください。'); return; }
|
||||
if (endDate && endDate < date) { setError('終了日は開始日以降にしてください。'); return; }
|
||||
if (!title.trim()) { setError(t('calendar.form.titleRequired')); return; }
|
||||
if (endDate && endDate < date) { setError(t('calendar.form.endAfterStart')); return; }
|
||||
// 終了時刻は開始時刻があるときだけ。単日は開始以降のみ(複数日は終了日側の時刻なので順序不問)。
|
||||
const effEndTime = time && endTime ? endTime : null;
|
||||
const isMultiDay = !!(endDate && endDate > date);
|
||||
if (effEndTime && !isMultiDay && effEndTime < time) {
|
||||
setError(t('calendar.form.endTimeAfterStart')); return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
@@ -595,6 +551,7 @@ function EventForm({
|
||||
// 終了日が開始日より後のときだけ複数日。それ以外は単日(null)に正規化。
|
||||
endDate: endDate && endDate > date ? endDate : null,
|
||||
time: time ? time : null,
|
||||
endTime: effEndTime,
|
||||
title: title.trim(),
|
||||
description: description ? description : null,
|
||||
};
|
||||
@@ -602,11 +559,11 @@ function EventForm({
|
||||
else await createCalendarEvent(spaceId, payload);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError((e as Error)?.message ?? '保存に失敗しました。');
|
||||
setError((e as Error)?.message ?? t('calendar.form.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [title, date, endDate, time, description, event, spaceId, onSaved]);
|
||||
}, [title, date, endDate, time, endTime, description, event, spaceId, onSaved]);
|
||||
|
||||
return (
|
||||
<div data-testid="space-cal-add-event" className="mb-2 space-y-2 rounded-md border border-hairline bg-surface p-2.5">
|
||||
@@ -615,11 +572,11 @@ function EventForm({
|
||||
data-testid="space-cal-event-title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="予定のタイトル"
|
||||
placeholder={t('calendar.form.titlePlaceholder')}
|
||||
className="w-full rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">開始</label>
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.start')}</label>
|
||||
<input
|
||||
type="date"
|
||||
data-testid="space-cal-event-date"
|
||||
@@ -636,7 +593,7 @@ function EventForm({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">終了</label>
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.end')}</label>
|
||||
<input
|
||||
type="date"
|
||||
data-testid="space-cal-event-end-date"
|
||||
@@ -645,21 +602,30 @@ function EventForm({
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
className="flex-1 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
{endDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEndDate('')}
|
||||
className="shrink-0 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-2xs text-slate-500 hover:bg-surface"
|
||||
>
|
||||
単日に戻す
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
type="time"
|
||||
data-testid="space-cal-event-end-time"
|
||||
value={endTime}
|
||||
disabled={!time}
|
||||
title={!time ? t('calendar.form.endTimeHint') : undefined}
|
||||
onChange={e => setEndTime(e.target.value)}
|
||||
className="w-28 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{endDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEndDate('')}
|
||||
className="self-start rounded-md border border-hairline bg-canvas px-2 py-1 text-2xs text-slate-500 hover:bg-surface"
|
||||
>
|
||||
{t('calendar.form.backToSingleDay')}
|
||||
</button>
|
||||
)}
|
||||
<textarea
|
||||
data-testid="space-cal-event-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="メモ(任意)"
|
||||
placeholder={t('calendar.form.notePlaceholder')}
|
||||
rows={2}
|
||||
className="w-full resize-none rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
@@ -671,7 +637,7 @@ function EventForm({
|
||||
disabled={saving}
|
||||
className="rounded-md border border-hairline bg-canvas px-2.5 py-1 text-2xs font-medium text-slate-600 transition-colors hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
キャンセル
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -680,7 +646,7 @@ function EventForm({
|
||||
disabled={saving}
|
||||
className="rounded-md bg-accent px-2.5 py-1 text-2xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{event ? '更新' : '追加'}
|
||||
{event ? t('calendar.form.update') : t('calendar.form.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSpaces, useUpdateSpace, useArchiveSpace } from '../../hooks/useSpaces';
|
||||
import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces';
|
||||
import { useLocalTaskList } from '../../hooks/useTaskList';
|
||||
import { useLocalTask, useLocalTaskComments } from '../../hooks/useTaskDetail';
|
||||
import { useTaskOperations } from '../../hooks/useTaskOperations';
|
||||
@@ -19,8 +19,10 @@ import type { DetailTabId, SortMode, StatusColumn } from '../../lib/urlState';
|
||||
import { filterTasksForSpace } from '../../lib/spaceTasks';
|
||||
import { filterAndSortTasks, groupTasksByStatus, statusCounts, totalTaskCount } from '../../lib/taskFilter';
|
||||
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
|
||||
import { workspaceDirRole } from '../../lib/workspaceDirs';
|
||||
import { FilterBar } from '../list/FilterBar';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable } from '../../lib/utils';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
|
||||
import type { OfficePreviewDescriptor } from '../files/FilePreview';
|
||||
import { CreateTaskDialog } from '../create/CreateTaskDialog';
|
||||
import { LocalTaskListItem } from '../list/TaskListItem';
|
||||
import { ChatPane } from '../chat/ChatPane';
|
||||
@@ -30,13 +32,19 @@ import { SchedulesPage } from '../../pages/SchedulesPage';
|
||||
import { SpaceApps } from './SpaceApps';
|
||||
import { useAuthState } from '../../App';
|
||||
import { SkeletonChatPane } from '../shared/Skeleton';
|
||||
import { EmptyState } from '../shared/EmptyState';
|
||||
import { SwipeableTabs } from '../mobile/SwipeableTabs';
|
||||
import { FilePreview } from '../files/FilePreview';
|
||||
import { FileTileGrid } from '../files/FileTileGrid';
|
||||
import { FileDetailList } from '../files/FileDetailList';
|
||||
import { useFileView } from '../../hooks/useFileView';
|
||||
import { FileBreadcrumb } from '../files/FileBreadcrumb';
|
||||
import { FileActions, FileSelectionBar, FileDropzone } from '../files/FileToolbar';
|
||||
import { MoveTargetDialog } from '../files/MoveTargetDialog';
|
||||
import { resolveMoves } from '../../lib/fileMove';
|
||||
import { FileActions, FileSelectionBar, FileDropzone, FileViewToggle, FileSortMenu, type FileSort } from '../files/FileToolbar';
|
||||
import { filesToBase64 } from '../../lib/fileBase64';
|
||||
import { AppRunner } from './AppRunner';
|
||||
import { createSpaceGateway } from './app-file-gateway';
|
||||
import { detectAppEntry } from './app-bridge';
|
||||
import { ChatDetailSplit } from './ChatDetailSplit';
|
||||
import { OutputPreviewProvider } from '../../lib/output-preview-context';
|
||||
@@ -47,13 +55,27 @@ import {
|
||||
fetchSpaceFiles,
|
||||
fetchSpaceFileContent,
|
||||
getSpaceFileRawUrl,
|
||||
getSpaceFileOfficePreviewUrl,
|
||||
getSpaceTrustedHtmlUrl,
|
||||
uploadSpaceFiles,
|
||||
deleteSpaceFiles,
|
||||
createSpaceFolder,
|
||||
moveSpaceFile,
|
||||
downloadSpaceFilesZip,
|
||||
type CreateLocalTaskInput,
|
||||
type LocalFileEntry,
|
||||
type Space,
|
||||
} from '../../api';
|
||||
import { SpaceFormDialog } from './SpaceFormDialog';
|
||||
|
||||
/** Filter state for the space chat list. Persisted in the URL by App (urlState)
|
||||
* so it survives tab switches, reloads and bookmarks. */
|
||||
export interface SpaceChatFilter {
|
||||
search: string;
|
||||
status: 'all' | StatusColumn;
|
||||
sort: SortMode;
|
||||
scope: TaskScope;
|
||||
}
|
||||
|
||||
interface SpaceDetailProps {
|
||||
spaceId?: string;
|
||||
@@ -62,11 +84,14 @@ interface SpaceDetailProps {
|
||||
onSelectSpaceTask: (id: number) => void;
|
||||
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
onOpenTask: (id: number) => void;
|
||||
chatFilter: SpaceChatFilter;
|
||||
onChatFilterChange: (next: Partial<SpaceChatFilter>) => void;
|
||||
}
|
||||
|
||||
type SpaceTab = 'chat' | 'files' | 'apps' | 'calendar' | 'schedules' | 'settings';
|
||||
|
||||
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpaceDetailProps) {
|
||||
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const { data: spaces } = useSpaces();
|
||||
const [tab, setTab] = useState<SpaceTab>('chat');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -98,8 +123,11 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
|
||||
if (!spaceId || !space) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6 text-sm text-slate-500">
|
||||
左の一覧からワークスペースを選んでください。
|
||||
<div className="flex h-full">
|
||||
<EmptyState
|
||||
title={t('detail.empty.title')}
|
||||
hint={t('detail.empty.hint')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,8 +146,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface px-4 py-3`}>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
|
||||
<SpaceHeaderTitle
|
||||
spaceId={spaceId}
|
||||
title={space.title}
|
||||
space={space}
|
||||
canManage={canManage}
|
||||
/>
|
||||
{space.kind === 'case' && (
|
||||
@@ -136,12 +163,12 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
|
||||
{/* Tabs */}
|
||||
<div className={`${hideOnMobileWhenChatOpen} items-center gap-1 border-b border-hairline px-3`}>
|
||||
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>チャット</TabButton>
|
||||
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>ファイル</TabButton>
|
||||
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>アプリ</TabButton>
|
||||
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>カレンダー</TabButton>
|
||||
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>スケジュール</TabButton>
|
||||
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>設定</TabButton>
|
||||
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
|
||||
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
|
||||
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
|
||||
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
|
||||
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
|
||||
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -156,6 +183,8 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
spaceTaskId={spaceTaskId}
|
||||
onSelectSpaceTask={onSelectSpaceTask}
|
||||
onCreateTask={onCreateTask}
|
||||
filter={chatFilter}
|
||||
onFilterChange={onChatFilterChange}
|
||||
/>
|
||||
)}
|
||||
{/* key={spaceId}: ワークスペースを切り替えたら detail のサブツリーを remount し、
|
||||
@@ -164,7 +193,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
ローカル state は remount しないと前のワークスペースの値が残る(空内容で上書き
|
||||
されない AGENTS.md の stale 表示など)。 */}
|
||||
{tab === 'files' && <SpaceFiles key={spaceId} spaceId={spaceId} canManage={canEditFiles} />}
|
||||
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} />}
|
||||
{tab === 'apps' && <SpaceApps key={spaceId} spaceId={spaceId} canManage={canManage} />}
|
||||
{tab === 'calendar' && (
|
||||
<SpaceCalendar
|
||||
key={spaceId}
|
||||
@@ -181,100 +210,46 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
}
|
||||
|
||||
/**
|
||||
* ヘッダーのワークスペース名。管理権限があればインラインで名前変更できる
|
||||
* (鉛筆ボタン → 入力 → 保存で PATCH → spaces を invalidate)。権限が無ければ
|
||||
* 単なるタイトル表示。
|
||||
* ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が
|
||||
* あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
|
||||
*/
|
||||
function SpaceHeaderTitle({
|
||||
spaceId,
|
||||
title,
|
||||
space,
|
||||
canManage,
|
||||
}: {
|
||||
spaceId: string;
|
||||
title: string;
|
||||
space: Space;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const updateSpace = useUpdateSpace();
|
||||
const { t } = useTranslation('spaces');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [value, setValue] = useState(title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 編集を開始したら現在のタイトルを入れ、フォーカスする。
|
||||
const startEdit = useCallback(() => {
|
||||
setValue(title);
|
||||
setEditing(true);
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.select();
|
||||
}, [editing]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const next = value.trim();
|
||||
if (!next || next === title) { setEditing(false); return; }
|
||||
try {
|
||||
await updateSpace.mutateAsync({ id: spaceId, patch: { title: next } });
|
||||
setEditing(false);
|
||||
} catch {
|
||||
// 失敗時は編集状態のまま(入力を消さない)。
|
||||
}
|
||||
}, [value, title, spaceId, updateSpace]);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<h1 className="min-w-0 truncate text-[15px] font-bold text-slate-800">{title}</h1>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-rename"
|
||||
onClick={startEdit}
|
||||
title="ワークスペース名を変更"
|
||||
aria-label="ワークスペース名を変更"
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="space-rename-input"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void save(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); setEditing(false); }
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded-md border border-hairline bg-canvas px-2 py-1 text-[15px] font-bold text-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-rename-save"
|
||||
onClick={() => void save()}
|
||||
disabled={updateSpace.isPending}
|
||||
title="保存"
|
||||
aria-label="保存"
|
||||
className="inline-flex h-7 items-center rounded-md bg-accent px-2 text-xs font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
title="キャンセル"
|
||||
aria-label="キャンセル"
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h1 className="shrink truncate text-[15px] font-bold text-slate-800">{space.title}</h1>
|
||||
{space.description && (
|
||||
<span
|
||||
data-testid="space-description"
|
||||
title={space.description}
|
||||
className="hidden min-w-0 shrink truncate text-xs text-slate-400 sm:inline"
|
||||
>
|
||||
{space.description}
|
||||
</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-edit"
|
||||
onClick={() => setEditing(true)}
|
||||
title={t('detail.editSpace')}
|
||||
aria-label={t('detail.editSpace')}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-surface hover:text-slate-700"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11 2.5l2.5 2.5M3 13l.5-2.5L11 3l2 2-7.5 7.5L3 13z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{editing && <SpaceFormDialog space={space} onClose={() => setEditing(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -293,6 +268,7 @@ function SpaceDeleteButton({
|
||||
title: string;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const archiveSpace = useArchiveSpace();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
@@ -309,7 +285,7 @@ function SpaceDeleteButton({
|
||||
if (confirming) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="hidden text-2xs text-slate-500 sm:inline">「{title}」を削除?</span>
|
||||
<span className="hidden text-2xs text-slate-500 sm:inline">{t('detail.deletePrompt', { title })}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-delete-confirm"
|
||||
@@ -317,14 +293,14 @@ function SpaceDeleteButton({
|
||||
disabled={archiveSpace.isPending}
|
||||
className="inline-flex h-7 items-center rounded-md bg-red-600 px-2 text-xs font-bold text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
削除する
|
||||
{t('detail.deleteConfirmButton')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-xs font-medium text-slate-600 transition-colors hover:bg-surface"
|
||||
>
|
||||
キャンセル
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -335,8 +311,8 @@ function SpaceDeleteButton({
|
||||
type="button"
|
||||
data-testid="space-delete"
|
||||
onClick={() => setConfirming(true)}
|
||||
title="ワークスペースを削除"
|
||||
aria-label="ワークスペースを削除"
|
||||
title={t('detail.deleteSpace')}
|
||||
aria-label={t('detail.deleteSpace')}
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -352,6 +328,7 @@ function SpaceDeleteButton({
|
||||
* 重なり表示で先頭から最大 4 名、超過は「+N」。クリックで設定タブ(メンバー管理)へ。
|
||||
*/
|
||||
function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage: () => void }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
// キーは SpaceMembersPanel と共有する(招待/ロール変更/除去の invalidate が
|
||||
// ヘッダーのアバター列にも即時反映されるように)。
|
||||
const { data: members } = useQuery({
|
||||
@@ -366,7 +343,7 @@ function SpaceMemberAvatars({ spaceId, onManage }: { spaceId: string; onManage:
|
||||
const MAX = 4;
|
||||
const shown = members.slice(0, MAX);
|
||||
const overflow = members.length - shown.length;
|
||||
const label = `共有メンバー ${members.length} 名: ${members.map(m => m.name ?? m.userId).join(', ')}`;
|
||||
const label = t('detail.sharedMembers', { count: members.length, names: members.map(m => m.name ?? m.userId).join(', ') });
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -433,25 +410,34 @@ function SpaceChat({
|
||||
isPersonalSpace,
|
||||
spaceTaskId,
|
||||
onSelectSpaceTask,
|
||||
filter,
|
||||
onFilterChange,
|
||||
}: {
|
||||
spaceId: string;
|
||||
isPersonalSpace: boolean;
|
||||
spaceTaskId?: number;
|
||||
onSelectSpaceTask: (id: number) => void;
|
||||
onCreateTask: SpaceDetailProps['onCreateTask'];
|
||||
filter: SpaceChatFilter;
|
||||
onFilterChange: (next: Partial<SpaceChatFilter>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const auth = useAuthState();
|
||||
const { data: allTasks } = useLocalTaskList();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [scope, setScope] = useState<TaskScope>('mine');
|
||||
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
|
||||
const setScope = (val: TaskScope) => onFilterChange({ scope: val });
|
||||
const setSearchQuery = (val: string) => onFilterChange({ search: val });
|
||||
const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val });
|
||||
const setSortMode = (val: SortMode) => onFilterChange({ sort: val });
|
||||
const spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace);
|
||||
|
||||
// 自分/他メンバーの切替。共有ワークスペースで「他の人のタスク」が存在するときだけ
|
||||
// 出す(個人ワークスペースや単独利用では意味がないので隠す)。スコープ分割は Tasks
|
||||
// ページと同じ filterTasksByScope を再利用する(owner_id null は others 側、という
|
||||
// 既存規約に合わせる)。SpaceChat は key={spaceId} で remount されるので、スペースを
|
||||
// 切り替えると scope は 'mine' に戻る。
|
||||
// 既存規約に合わせる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の
|
||||
// onSelectSpace が search/status/sort/scope を明示リセットする(remount 依存ではない)。
|
||||
const userId = auth.mode === 'authenticated' ? auth.user.id : null;
|
||||
const hasOthersTasks = userId != null && spaceTasks.some(t => t.ownerId !== userId);
|
||||
const tasks = userId != null && hasOthersTasks
|
||||
@@ -459,10 +445,8 @@ function SpaceChat({
|
||||
: spaceTasks;
|
||||
|
||||
// 検索・ステータス・ソート(Tasks ページの FilterBar と同じ挙動を共有ヘルパーで再現)。
|
||||
// scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<'all' | StatusColumn>('all');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('updated');
|
||||
// scope(自分/他メンバー)が最外側のフィルタで、その結果に対して絞り込む。state は
|
||||
// URL 永続化のため SpaceDetail(親)→ App の urlState から流す。
|
||||
const statusColumns = groupTasksByStatus(tasks);
|
||||
const counts = statusCounts(statusColumns);
|
||||
const totalCount = totalTaskCount(statusColumns);
|
||||
@@ -490,14 +474,14 @@ function SpaceChat({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-hairline">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">このワークスペースのチャット</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">{t('chat.listHeading')}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-new-chat-btn"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="shrink-0 rounded-md bg-accent px-2.5 py-1.5 text-xs font-bold text-accent-fg transition-colors hover:opacity-90"
|
||||
>
|
||||
+ 新規
|
||||
{t('chat.new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -506,7 +490,7 @@ function SpaceChat({
|
||||
data-testid="space-chat-scope-toggle"
|
||||
className="flex items-center gap-1 border-b border-hairline px-3 py-1.5"
|
||||
>
|
||||
{([['mine', '自分'], ['others', '他のメンバー']] as const).map(([val, label]) => (
|
||||
{(['mine', 'others'] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
@@ -519,7 +503,7 @@ function SpaceChat({
|
||||
: 'text-slate-500 hover:bg-surface hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{t(`chat.scope.${val}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -542,14 +526,21 @@ function SpaceChat({
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
{totalCount === 0 ? (
|
||||
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||||
{hasOthersTasks && scope === 'others'
|
||||
? '他のメンバーが作成したチャットはありません。'
|
||||
: 'このワークスペースにはまだチャットがありません。「+ 新規」で始めましょう。'}
|
||||
</p>
|
||||
hasOthersTasks && scope === 'others' ? (
|
||||
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||||
{t('chat.empty.others')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||||
<p>{t('chat.empty.none')}</p>
|
||||
<p className="mt-1.5 text-2xs text-slate-400 leading-relaxed">
|
||||
{t('chat.empty.filesHint')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : visibleTasks.length === 0 ? (
|
||||
<p className="rounded-md border border-hairline bg-surface p-3 text-sm text-slate-500">
|
||||
条件に一致するチャットがありません。
|
||||
{t('chat.empty.noMatch')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -580,7 +571,7 @@ function SpaceChat({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-6 text-center text-sm text-slate-500">
|
||||
左の一覧からチャットを選ぶか、「+ 新規」で始めてください。
|
||||
{t('chat.selectHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -602,6 +593,7 @@ function SpaceChat({
|
||||
// スペース内インライン会話。App の Tasks 詳細と同じ hook / handler を使い、挙動
|
||||
// (追加指示送信・キャンセル・ライブ表示)を完全一致させる。
|
||||
function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => void }) {
|
||||
const { t: ts } = useTranslation('spaces');
|
||||
const { toast, showToast } = useToast();
|
||||
const taskQuery = useLocalTask(taskId, true);
|
||||
const commentsQuery = useLocalTaskComments(taskId, true);
|
||||
@@ -637,7 +629,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
|
||||
// 削除(確認ダイアログ付き)。confirm をキャンセルしたら何もしない。
|
||||
const confirmAndDelete = useCallback(async () => {
|
||||
if (!window.confirm('このチャットを削除しますか?この操作は取り消せません。')) return;
|
||||
if (!window.confirm(ts('conversation.deleteConfirm'))) return;
|
||||
await handleDelete();
|
||||
}, [handleDelete]);
|
||||
|
||||
@@ -788,7 +780,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 4l-4 4 4 4" />
|
||||
</svg>
|
||||
一覧へ
|
||||
{ts('conversation.backToList')}
|
||||
</button>
|
||||
|
||||
{/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と
|
||||
@@ -804,10 +796,10 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */}
|
||||
<span
|
||||
data-testid="space-chat-visibility-note"
|
||||
title="このワークスペースのメンバーだけが閲覧できます"
|
||||
title={ts('conversation.visibilityTitle')}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-500"
|
||||
>
|
||||
🔒 このワークスペースのメンバーに公開
|
||||
{ts('conversation.visibilityNote')}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
@@ -826,8 +818,8 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
type="button"
|
||||
data-testid="space-chat-delete"
|
||||
onClick={() => void confirmAndDelete()}
|
||||
title="削除"
|
||||
aria-label="削除"
|
||||
title={ts('common:delete')}
|
||||
aria-label={ts('common:delete')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -930,6 +922,7 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
section={previewState.section}
|
||||
filePath={previewState.filePath}
|
||||
editable={previewState.editable}
|
||||
office={previewState.office}
|
||||
/>
|
||||
)}
|
||||
{toast && (
|
||||
@@ -953,6 +946,7 @@ interface SpacePreviewState {
|
||||
imageSrc: string;
|
||||
markdownImageBaseUrl?: string;
|
||||
trustedHtmlUrl?: string;
|
||||
office?: OfficePreviewDescriptor;
|
||||
}
|
||||
|
||||
// ソースライブラリの curated 一覧 UI は撤去した(#6)。エージェントが取得した資料は
|
||||
@@ -964,6 +958,7 @@ interface SpacePreviewState {
|
||||
// 自分のワークスペースなので削除可)。共有ワークスペースでは SpaceDetail が
|
||||
// owner/admin 判定を渡す。サーバ側も canEditInSpace で再度ゲートする。
|
||||
export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; canManage?: boolean }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [entries, setEntries] = useState<LocalFileEntry[]>([]);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
@@ -977,6 +972,9 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isMoving, setIsMoving] = useState(false);
|
||||
// 「移動」ダイアログで移動する対象(null = 閉じている)。
|
||||
const [moveDialogSources, setMoveDialogSources] = useState<string[] | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
@@ -986,7 +984,7 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
setLoadError('');
|
||||
} catch {
|
||||
setEntries([]);
|
||||
setLoadError('ファイルの取得に失敗しました');
|
||||
setLoadError(t('files.loadError'));
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
@@ -999,6 +997,16 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
|
||||
const handlePreview = useCallback(async (filePath: string, name: string) => {
|
||||
try {
|
||||
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
|
||||
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
|
||||
setPreview({
|
||||
name,
|
||||
content: '',
|
||||
imageSrc: '',
|
||||
office: { kind, url: getSpaceFileOfficePreviewUrl(spaceId, filePath), downloadUrl: getSpaceFileRawUrl(spaceId, filePath) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isImagePreviewable(name) || isPdfPreviewable(name) || isHtmlPreviewable(name)) {
|
||||
const imageSrc = getSpaceFileRawUrl(spaceId, filePath);
|
||||
const trustedHtmlUrl = isHtmlPreviewable(name) ? getSpaceTrustedHtmlUrl(spaceId, filePath) : undefined;
|
||||
@@ -1013,7 +1021,7 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
}
|
||||
setPreview({ name, content, imageSrc: '', markdownImageBaseUrl });
|
||||
} catch {
|
||||
setLoadError('ファイルの読み込みに失敗しました');
|
||||
setLoadError(t('files.previewError'));
|
||||
}
|
||||
}, [spaceId]);
|
||||
|
||||
@@ -1025,14 +1033,88 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
const payload = await filesToBase64(fileList);
|
||||
const r = await uploadSpaceFiles(spaceId, currentPath, payload);
|
||||
await load();
|
||||
setUploadMsg({ text: `${r.uploaded.length} 件のファイルを追加しました`, kind: 'ok' });
|
||||
setUploadMsg({ text: t('files.uploadedCount', { count: r.uploaded.length }), kind: 'ok' });
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: `アップロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
|
||||
setUploadMsg({ text: t('files.uploadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [spaceId, currentPath, load]);
|
||||
|
||||
// 現在フォルダに空フォルダを作る。既存スペースに readonly/ を後付けする用途も兼ねる。
|
||||
const createFolder = useCallback(async () => {
|
||||
const name = window.prompt(t('files.newFolderPrompt'))?.trim();
|
||||
if (!name) return;
|
||||
if (/[\\/]/.test(name)) {
|
||||
setUploadMsg({ text: t('files.folderNameInvalid'), kind: 'error' });
|
||||
return;
|
||||
}
|
||||
const rel = currentPath ? `${currentPath}/${name}` : name;
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
await createSpaceFolder(spaceId, rel);
|
||||
await load();
|
||||
setUploadMsg({ text: t('files.folderCreated', { name }), kind: 'ok' });
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: t('files.folderCreateFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
}
|
||||
}, [spaceId, currentPath, load]);
|
||||
|
||||
// ファイル/フォルダのリネーム(move エンドポイント経由)。構造ディレクトリ
|
||||
// (input/output/logs/apps/readonly)はサーバ側で拒否され、UI でも行アクションを出さない。
|
||||
const renameEntry = useCallback(async (entry: LocalFileEntry) => {
|
||||
const next = window.prompt(t('files.renamePrompt'), entry.name)?.trim();
|
||||
if (!next || next === entry.name) return;
|
||||
if (/[\\/]/.test(next)) {
|
||||
setUploadMsg({ text: t('files.nameInvalid'), kind: 'error' });
|
||||
return;
|
||||
}
|
||||
const slash = entry.path.lastIndexOf('/');
|
||||
const parent = slash >= 0 ? entry.path.slice(0, slash) : '';
|
||||
const to = parent ? `${parent}/${next}` : next;
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const r = await moveSpaceFile(spaceId, entry.path, to);
|
||||
await load();
|
||||
setUploadMsg({ text: t('files.renamed', { from: entry.name, to: r.to.split('/').pop() }), kind: 'ok' });
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: t('files.renameFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
}
|
||||
}, [spaceId, load]);
|
||||
|
||||
// ファイル/フォルダをフォルダへ移動する(ドラッグ移動・複数選択移動の共通経路)。
|
||||
// resolveMoves が no-op / 自己内移動を事前除外し、残りを move エンドポイントへ順に投げる。
|
||||
// 衝突はサーバが自動リネームするため、ここでは件数だけ報告する。
|
||||
const moveInto = useCallback(async (sourcePaths: string[], destDir: string) => {
|
||||
const { moves, skipped } = resolveMoves(sourcePaths, destDir);
|
||||
if (moves.length === 0) {
|
||||
if (skipped.length > 0) setUploadMsg({ text: t('files.alreadyThere'), kind: 'ok' });
|
||||
return;
|
||||
}
|
||||
setIsMoving(true);
|
||||
setUploadMsg(null);
|
||||
let moved = 0;
|
||||
let failed = 0;
|
||||
for (const m of moves) {
|
||||
try {
|
||||
await moveSpaceFile(spaceId, m.from, m.to);
|
||||
moved++;
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
const where = destDir ? t('files.moveTargetFolder', { name: destDir.split('/').pop() }) : t('files.moveTargetRoot');
|
||||
setUploadMsg(
|
||||
failed > 0
|
||||
// 一部でも失敗したら赤で示す(成功緑だと失敗を見落とすため)。
|
||||
? { text: t('files.movedWithFailures', { moved, where, failed }), kind: 'error' }
|
||||
: { text: t('files.moved', { moved, where }), kind: 'ok' },
|
||||
);
|
||||
setIsMoving(false);
|
||||
}, [spaceId, load]);
|
||||
|
||||
// フォルダ移動・ワークスペース切替で選択をクリア(別ディレクトリのパスを持ち越さない)。
|
||||
useEffect(() => { setSelected(new Set()); }, [currentPath, spaceId]);
|
||||
|
||||
@@ -1048,16 +1130,16 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
// ガード + canEditInSpace で再ゲートするが、UI は相対パスだけを送る。
|
||||
const deleteSelected = useCallback(async (paths: string[]) => {
|
||||
if (paths.length === 0) return;
|
||||
if (!window.confirm(`${paths.length} 件のファイルを削除しますか?この操作は取り消せません。`)) return;
|
||||
if (!window.confirm(t('files.deleteConfirm', { count: paths.length }))) return;
|
||||
setIsDeleting(true);
|
||||
setUploadMsg(null);
|
||||
try {
|
||||
const r = await deleteSpaceFiles(spaceId, paths);
|
||||
setSelected(new Set());
|
||||
await load();
|
||||
setUploadMsg({ text: `${r.deleted.length} 件のファイルを削除しました`, kind: 'ok' });
|
||||
setUploadMsg({ text: t('files.deletedCount', { count: r.deleted.length }), kind: 'ok' });
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: `削除に失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
|
||||
setUploadMsg({ text: t('files.deleteFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -1071,46 +1153,98 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
try {
|
||||
await downloadSpaceFilesZip(spaceId, paths);
|
||||
} catch (e) {
|
||||
setUploadMsg({ text: `ダウンロードに失敗しました: ${(e as Error)?.message ?? ''}`, kind: 'error' });
|
||||
setUploadMsg({ text: t('files.downloadFailed', { msg: (e as Error)?.message ?? '' }), kind: 'error' });
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}, [spaceId]);
|
||||
|
||||
const pathSegments = currentPath ? currentPath.split('/').filter(Boolean) : [];
|
||||
const dirs = entries.filter(e => e.kind === 'directory');
|
||||
// source/index.jsonl は「ソース」グループのデータ源なので、生のファイル一覧には
|
||||
// 出さない(ノイズ回避)。source/ フォルダ自体は通常どおりブラウズできる。
|
||||
const files = entries.filter(
|
||||
e => e.kind !== 'directory' && !(currentPath === 'source' && e.name === 'index.jsonl'),
|
||||
const visibleEntries = entries.filter(
|
||||
e => !(e.kind !== 'directory' && currentPath === 'source' && e.name === 'index.jsonl'),
|
||||
);
|
||||
const sorted = [
|
||||
...dirs.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
...files.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
];
|
||||
const { viewMode, setViewMode, sort, setSort, toggleSort, sortedEntries } = useFileView(visibleEntries);
|
||||
// アイコン表示のドロップダウン(名前順/新しい順)は共有ソート状態に縮約して乗せる。
|
||||
// サイズ順は詳細表示の列見出しから操作する(タスク窓の FileBrowser と同方針)。
|
||||
const menuSort: FileSort = sort.key === 'modified' ? 'newest' : 'name';
|
||||
const onMenuSort = (s: FileSort) =>
|
||||
setSort(s === 'newest' ? { key: 'modified', dir: 'desc' } : { key: 'name', dir: 'asc' });
|
||||
|
||||
// 選択可能なのはファイルのみ(ディレクトリは削除対象外)。
|
||||
const selectablePaths = files.map(f => f.path);
|
||||
// 選択可能なのはファイル+ユーザー作成フォルダ(構造フォルダ=workspaceDirRole 有 は保護で除外)。
|
||||
const selectablePaths = visibleEntries
|
||||
.filter(e => e.kind !== 'directory' || workspaceDirRole(e.path, e.name, e.kind) == null)
|
||||
.map(f => f.path);
|
||||
const allSelected = selectablePaths.length > 0 && selectablePaths.every(p => selected.has(p));
|
||||
const selectedInView = selectablePaths.filter(p => selected.has(p));
|
||||
|
||||
// リネームボタン(詳細表示・アイコン表示で共有)。構造ディレクトリ
|
||||
// (input/output/logs/apps/readonly)はサーバ側で拒否されるため出さない。
|
||||
const renameButton = (entry: LocalFileEntry) => {
|
||||
const isStructural = workspaceDirRole(entry.path, entry.name, entry.kind) != null;
|
||||
if (!canManage || isStructural) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-file-rename-${entry.name}`}
|
||||
onClick={() => void renameEntry(entry)}
|
||||
title={t('files.rename')}
|
||||
aria-label={t('files.renameAria', { name: entry.name })}
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded text-slate-400 opacity-0 transition-opacity hover:bg-surface-2 hover:text-slate-700 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M10.5 2.5l3 3L6 13l-3.5.5L3 10z" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="space-files" className="flex flex-col gap-3">
|
||||
{/* パンくず(現在地)+ 操作。パスはパンくずのみで表す(テキスト二重表示を廃止)。 */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 pt-1 font-mono text-2xs text-slate-500 break-all">
|
||||
/files{currentPath ? `/${currentPath}` : ''}
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<FileBreadcrumb
|
||||
testid="space-files-breadcrumb"
|
||||
pathSegments={pathSegments}
|
||||
onNavigate={setCurrentPath}
|
||||
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{viewMode === 'icon' && <FileSortMenu sort={menuSort} onChange={onMenuSort} />}
|
||||
<FileViewToggle idPrefix="space" mode={viewMode} onChange={setViewMode} />
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-files-mkdir-btn"
|
||||
onClick={() => void createFolder()}
|
||||
className="inline-flex items-center gap-1 px-2 h-7 rounded text-2xs font-medium border border-hairline bg-canvas text-slate-600 hover:bg-surface transition-colors"
|
||||
title={t('files.mkdirTitle')}
|
||||
>
|
||||
<svg className="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M2 4.5A1.5 1.5 0 013.5 3h2.6l1.2 1.6h5.2A1.5 1.5 0 0116 6.1V12a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 012 12V4.5zM8 7.5v4M6 9.5h4" />
|
||||
</svg>
|
||||
{t('files.newFolder')}
|
||||
</button>
|
||||
)}
|
||||
<FileActions
|
||||
idPrefix="space"
|
||||
canUpload={canManage}
|
||||
onUploadFiles={files => void uploadFiles(files)}
|
||||
onRefresh={() => void load()}
|
||||
isRefreshing={isRefreshing}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
</div>
|
||||
<FileActions
|
||||
idPrefix="space"
|
||||
canUpload={canManage}
|
||||
onUploadFiles={files => void uploadFiles(files)}
|
||||
onRefresh={() => void load()}
|
||||
isRefreshing={isRefreshing}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FileBreadcrumb testid="space-files-breadcrumb" pathSegments={pathSegments} onNavigate={setCurrentPath} />
|
||||
{!currentPath && !loadError && selectablePaths.length === 0 && (
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||||
{t('files.inputHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canManage && selectablePaths.length > 0 && (
|
||||
<FileSelectionBar
|
||||
@@ -1120,8 +1254,10 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
selectedCount={selectedInView.length}
|
||||
onDeleteSelected={() => void deleteSelected(selectedInView)}
|
||||
onDownloadSelected={() => void downloadSelected(selectedInView)}
|
||||
onMoveSelected={() => setMoveDialogSources(selectedInView)}
|
||||
isDeleting={isDeleting}
|
||||
isDownloading={isDownloading}
|
||||
isMoving={isMoving}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1135,39 +1271,87 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
enabled={canManage}
|
||||
isUploading={isUploading}
|
||||
onDropFiles={files => void uploadFiles(files)}
|
||||
onRejectFolder={() => setUploadMsg({ text: 'フォルダは未対応です。ファイルを選んでください。', kind: 'error' })}
|
||||
onRejectFolder={() => setUploadMsg({ text: t('files.folderNotSupported'), kind: 'error' })}
|
||||
>
|
||||
<FileTileGrid
|
||||
entries={sorted}
|
||||
idPrefix="space"
|
||||
canManage={canManage}
|
||||
selected={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenDir={setCurrentPath}
|
||||
onOpenFile={(path, name) => void handlePreview(path, name)}
|
||||
onDeleteOne={path => void deleteSelected([path])}
|
||||
isDeleting={isDeleting}
|
||||
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
|
||||
renderTileOverlay={entry => {
|
||||
// apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。
|
||||
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
|
||||
if (!appEntry) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-run-${appEntry.appName}`}
|
||||
onClick={() => setAppToRun(appEntry)}
|
||||
title="アプリとして実行"
|
||||
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
アプリとして実行
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
emptyHint={loadError ? null : (canManage
|
||||
? 'ファイルがありません。ここにドラッグ&ドロップ、または「+ 追加」で追加できます。'
|
||||
: 'ファイルがありません。')}
|
||||
/>
|
||||
{viewMode === 'detail' ? (
|
||||
<FileDetailList
|
||||
entries={sortedEntries}
|
||||
idPrefix="space"
|
||||
canManage={canManage}
|
||||
selected={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenDir={setCurrentPath}
|
||||
onOpenFile={(path, name) => void handlePreview(path, name)}
|
||||
onDeleteOne={path => void deleteSelected([path])}
|
||||
isDeleting={isDeleting}
|
||||
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
|
||||
onDownloadDir={path => void downloadSelected([path])}
|
||||
sort={sort}
|
||||
onSort={toggleSort}
|
||||
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
|
||||
renderRowAction={entry => {
|
||||
// 詳細表示でも apps/{name}/index.html は「実行」で起動できるようにする
|
||||
// (アイコン表示の renderTileOverlay と対)。
|
||||
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
|
||||
const rename = renameButton(entry);
|
||||
if (!appEntry && !rename) return null;
|
||||
return (
|
||||
<>
|
||||
{rename}
|
||||
{appEntry && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-run-${appEntry.appName}`}
|
||||
onClick={() => setAppToRun(appEntry)}
|
||||
title={t('files.runAsApp')}
|
||||
className="inline-flex h-5 items-center rounded bg-[var(--brand-primary)] px-1.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-90 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
{t('files.run')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
emptyHint={loadError ? null : (canManage
|
||||
? t('files.emptyManage')
|
||||
: t('files.empty'))}
|
||||
/>
|
||||
) : (
|
||||
<FileTileGrid
|
||||
entries={sortedEntries}
|
||||
idPrefix="space"
|
||||
canManage={canManage}
|
||||
selected={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onOpenDir={setCurrentPath}
|
||||
onOpenFile={(path, name) => void handlePreview(path, name)}
|
||||
onDeleteOne={path => void deleteSelected([path])}
|
||||
isDeleting={isDeleting}
|
||||
fileHref={entry => getSpaceFileRawUrl(spaceId, entry.path)}
|
||||
onDownloadDir={path => void downloadSelected([path])}
|
||||
onMoveDrop={canManage ? (s, d) => void moveInto(s, d) : undefined}
|
||||
renderEntryAction={renameButton}
|
||||
renderTileOverlay={entry => {
|
||||
// apps/{name}/index.html なら「アプリとして実行」を出す(Stage 1 起動経路)。
|
||||
const appEntry = entry.kind !== 'directory' ? detectAppEntry(entry.path) : null;
|
||||
if (!appEntry) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`space-app-run-${appEntry.appName}`}
|
||||
onClick={() => setAppToRun(appEntry)}
|
||||
title={t('files.runAsApp')}
|
||||
className="absolute bottom-1 left-1/2 -translate-x-1/2 rounded bg-[var(--brand-primary)] px-2 py-0.5 text-2xs font-medium text-white opacity-0 transition-opacity hover:opacity-100 focus-visible:opacity-100 group-hover:opacity-100 reveal-hover"
|
||||
>
|
||||
{t('files.runAsApp')}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
emptyHint={loadError ? null : (canManage
|
||||
? t('files.emptyManage')
|
||||
: t('files.empty'))}
|
||||
/>
|
||||
)}
|
||||
</FileDropzone>
|
||||
|
||||
{preview && (
|
||||
@@ -1177,18 +1361,32 @@ export function SpaceFiles({ spaceId, canManage = true }: { spaceId: string; can
|
||||
imageSrc={preview.imageSrc}
|
||||
markdownImageBaseUrl={preview.markdownImageBaseUrl}
|
||||
trustedHtmlUrl={preview.trustedHtmlUrl}
|
||||
office={preview.office}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{appToRun && (
|
||||
<AppRunner
|
||||
spaceId={spaceId}
|
||||
gateway={createSpaceGateway(spaceId)}
|
||||
appName={appToRun.appName}
|
||||
entryPath={appToRun.entryPath}
|
||||
onClose={() => setAppToRun(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{moveDialogSources && (
|
||||
<MoveTargetDialog
|
||||
spaceId={spaceId}
|
||||
sourcePaths={moveDialogSources}
|
||||
isMoving={isMoving}
|
||||
onClose={() => setMoveDialogSources(null)}
|
||||
onConfirm={dest => {
|
||||
setMoveDialogSources(null);
|
||||
void moveInto(moveDialogSources, dest);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+51
-29
@@ -1,41 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useCreateSpace } from '../../hooks/useSpaces';
|
||||
import { useCreateSpace, useUpdateSpace } from '../../hooks/useSpaces';
|
||||
import type { Space } from '../../api';
|
||||
|
||||
interface CreateSpaceDialogProps {
|
||||
interface SpaceFormDialogProps {
|
||||
/** 指定すると編集モード(名前・色・説明を更新)。未指定なら新規作成モード。 */
|
||||
space?: Space;
|
||||
onClose: () => void;
|
||||
onCreated?: (id: string) => void;
|
||||
onSaved?: (id: string) => void;
|
||||
}
|
||||
|
||||
// DESIGN.md のブランド設定 UI に倣ったプリセット色。
|
||||
const PRESET_COLORS = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#64748b'];
|
||||
|
||||
export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps) {
|
||||
/**
|
||||
* ワークスペースの新規作成・編集を兼ねるダイアログ。`space` を渡すと編集モードに
|
||||
* なり、名前・ブランド色・説明をまとめて更新する(種類は変更不可なので出さない)。
|
||||
*/
|
||||
export function SpaceFormDialog({ space, onClose, onSaved }: SpaceFormDialogProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const isEdit = !!space;
|
||||
const createSpace = useCreateSpace();
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [brandColor, setBrandColor] = useState<string>(PRESET_COLORS[0]);
|
||||
const updateSpace = useUpdateSpace();
|
||||
const [title, setTitle] = useState(space?.title ?? '');
|
||||
const [description, setDescription] = useState(space?.description ?? '');
|
||||
const [brandColor, setBrandColor] = useState<string>(space?.brandColor ?? PRESET_COLORS[0]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const submitting = createSpace.isPending;
|
||||
const submitting = createSpace.isPending || updateSpace.isPending;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) {
|
||||
setError('ワークスペース名を入力してください。');
|
||||
setError(t('formDialog.error.nameRequired'));
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
try {
|
||||
const space = await createSpace.mutateAsync({
|
||||
title: trimmed,
|
||||
description: description.trim() || undefined,
|
||||
brandColor: brandColor || null,
|
||||
});
|
||||
onCreated?.(space.id);
|
||||
if (isEdit) {
|
||||
const updated = await updateSpace.mutateAsync({
|
||||
id: space!.id,
|
||||
patch: { title: trimmed, description: description.trim(), brandColor: brandColor || null },
|
||||
});
|
||||
onSaved?.(updated.id);
|
||||
} else {
|
||||
const created = await createSpace.mutateAsync({
|
||||
title: trimmed,
|
||||
description: description.trim() || undefined,
|
||||
brandColor: brandColor || null,
|
||||
});
|
||||
onSaved?.(created.id);
|
||||
}
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'ワークスペースを作成できませんでした。');
|
||||
setError(e instanceof Error ? e.message : isEdit ? t('formDialog.error.updateFailed') : t('formDialog.error.createFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,15 +70,17 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
|
||||
<div className="flex items-start justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
|
||||
新規ワークスペース
|
||||
{isEdit ? t('formDialog.title.edit') : t('formDialog.title.create')}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
|
||||
クライアントや案件ごとに、成果が蓄積する作業場所を作ります。
|
||||
{isEdit
|
||||
? t('formDialog.description.edit')
|
||||
: t('formDialog.description.create')}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
aria-label="閉じる"
|
||||
aria-label={t('formDialog.close')}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
@@ -71,44 +92,45 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-slate-600">ワークスペース名<span className="text-red-500"> *</span></span>
|
||||
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.name')}<span className="text-red-500"> *</span></span>
|
||||
<input
|
||||
autoFocus
|
||||
data-testid="space-title-input"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="例: ◯◯社 受託PJ"
|
||||
placeholder={t('formDialog.field.namePlaceholder')}
|
||||
className="rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-slate-600">説明(任意)</span>
|
||||
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.description')}</span>
|
||||
<textarea
|
||||
data-testid="space-description-input"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="このワークスペースで扱う案件の概要"
|
||||
placeholder={t('formDialog.field.descriptionPlaceholder')}
|
||||
className="resize-none rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-semibold text-slate-600">ブランド色</span>
|
||||
<span className="text-xs font-semibold text-slate-600">{t('formDialog.field.brandColor')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setBrandColor(c)}
|
||||
aria-label={`色 ${c}`}
|
||||
aria-label={t('formDialog.colorSwatch', { color: c })}
|
||||
className={`h-6 w-6 rounded-full transition-transform ${
|
||||
brandColor === c ? 'ring-2 ring-offset-2 ring-[var(--brand-primary)]' : ''
|
||||
}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
<label className="ml-1 flex cursor-pointer items-center" title="自由に選択">
|
||||
<label className="ml-1 flex cursor-pointer items-center" title={t('formDialog.customColor')}>
|
||||
<input
|
||||
type="color"
|
||||
value={brandColor}
|
||||
@@ -127,17 +149,17 @@ export function CreateSpaceDialog({ onClose, onCreated }: CreateSpaceDialogProps
|
||||
type="button"
|
||||
className="rounded-md border border-hairline px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
キャンセル
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-space-submit"
|
||||
data-testid="space-form-submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting}
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '作成中…' : '作成'}
|
||||
{submitting ? (isEdit ? t('formDialog.saving') : t('formDialog.creating')) : (isEdit ? t('common:save') : t('formDialog.createButton'))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,6 +16,7 @@
|
||||
* 「認証を有効化するとスペースを共有できます」の案内を出す。
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceMembers,
|
||||
@@ -35,12 +36,6 @@ import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
const ROLE_LABEL: Record<SpaceMemberRole, string> = {
|
||||
owner: 'オーナー',
|
||||
editor: '編集者',
|
||||
viewer: '閲覧者',
|
||||
};
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
@@ -58,6 +53,7 @@ function Avatar({ url, name }: { url: string | null; name: string | null }) {
|
||||
}
|
||||
|
||||
export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -82,24 +78,24 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
mutationFn: ({ userId, role }: { userId: string; role: SpaceMemberRole }) =>
|
||||
updateSpaceMemberRole(spaceId, userId, role),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => showToast?.(`ロールの変更に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.roleChangeFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (userId: string) => removeSpaceMember(spaceId, userId),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => showToast?.(`メンバーの除去に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.removeFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: (input: { userId: string; role: SpaceMemberRole }) => addSpaceMember(spaceId, input),
|
||||
onSuccess: () => { invalidate(); setPicking(false); },
|
||||
onError: (e) => showToast?.(`メンバーの追加に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.addFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const handleRemove = (m: SpaceMember) => {
|
||||
const who = m.name ?? m.email ?? m.userId;
|
||||
if (window.confirm(`${who} をこのワークスペースから除去しますか?`)) {
|
||||
if (window.confirm(t('members.removeConfirm', { who }))) {
|
||||
removeMut.mutate(m.userId);
|
||||
}
|
||||
};
|
||||
@@ -108,15 +104,15 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
<div className="h-full overflow-y-auto" data-testid="space-members-panel">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">メンバー</h2>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('members.heading')}</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
このワークスペースを共有しているメンバーです。編集者はタスク・ファイル・カレンダーを編集でき、閲覧者は閲覧のみ可能です。
|
||||
{t('members.intro')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-[13px] text-slate-400">読み込み中…</div>}
|
||||
{isLoading && <div className="text-[13px] text-slate-400">{t('common:loading')}</div>}
|
||||
{isError && (
|
||||
<div className="text-[13px] text-red-600">メンバーの取得に失敗しました: {errMsg(error)}</div>
|
||||
<div className="text-[13px] text-red-600">{t('members.fetchError', { msg: errMsg(error) })}</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && (
|
||||
@@ -135,7 +131,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
</span>
|
||||
{m.isOwner && (
|
||||
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-blue-50 dark:bg-blue-500/15 text-blue-600 dark:text-blue-300 leading-none">
|
||||
オーナー
|
||||
{t('members.role.owner')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -154,8 +150,8 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
}
|
||||
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
<option value="editor">{ROLE_LABEL.editor}</option>
|
||||
<option value="viewer">{ROLE_LABEL.viewer}</option>
|
||||
<option value="editor">{t('members.role.editor')}</option>
|
||||
<option value="viewer">{t('members.role.viewer')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,7 +160,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
disabled={removeMut.isPending}
|
||||
className="text-2xs text-red-600 hover:text-red-800 dark:hover:text-red-300 underline disabled:opacity-50"
|
||||
>
|
||||
除去
|
||||
{t('members.remove')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -172,7 +168,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
data-testid={`space-member-role-${m.userId}`}
|
||||
className="text-2xs text-slate-500"
|
||||
>
|
||||
{ROLE_LABEL[m.role]}
|
||||
{t(`members.role.${m.role}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -204,7 +200,7 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
onClick={() => setPicking(true)}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-semibold text-accent border border-accent/30 hover:bg-accent-soft transition-colors"
|
||||
>
|
||||
メンバーを招待
|
||||
{t('members.inviteButton')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -214,15 +210,10 @@ export function SpaceMembersPanel({ spaceId, showToast }: { spaceId: string; sho
|
||||
);
|
||||
}
|
||||
|
||||
const INVITE_ROLE_LABEL: Record<SpaceInviteRole, string> = {
|
||||
editor: '編集者',
|
||||
viewer: '閲覧者',
|
||||
};
|
||||
|
||||
const EXPIRY_OPTIONS: Array<{ label: string; days: number | null }> = [
|
||||
{ label: '無期限', days: null },
|
||||
{ label: '7日', days: 7 },
|
||||
{ label: '30日', days: 30 },
|
||||
const EXPIRY_OPTIONS: Array<{ labelKey: string; days: number | null }> = [
|
||||
{ labelKey: 'members.invite.expiry.never', days: null },
|
||||
{ labelKey: 'members.invite.expiry.days7', days: 7 },
|
||||
{ labelKey: 'members.invite.expiry.days30', days: 30 },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -231,6 +222,7 @@ const EXPIRY_OPTIONS: Array<{ label: string; days: number | null }> = [
|
||||
* 組織(pickable の絞り込み)に依存しない招待経路になる。
|
||||
*/
|
||||
function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const qc = useQueryClient();
|
||||
const [role, setRole] = useState<SpaceInviteRole>('viewer');
|
||||
const [expiryIdx, setExpiryIdx] = useState(0);
|
||||
@@ -247,13 +239,13 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => createSpaceInvite(spaceId, { role, expiresInDays: EXPIRY_OPTIONS[expiryIdx].days }),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => showToast?.(`招待リンクの作成に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.inviteCreateFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const revokeMut = useMutation({
|
||||
mutationFn: () => revokeSpaceInvite(spaceId),
|
||||
onSuccess: invalidate,
|
||||
onError: (e) => showToast?.(`招待リンクの無効化に失敗しました: ${errMsg(e)}`, 'error'),
|
||||
onError: (e) => showToast?.(t('members.toast.inviteRevokeFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const absoluteUrl = invite ? `${window.location.origin}${invite.url}` : '';
|
||||
@@ -265,21 +257,21 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
showToast?.('コピーに失敗しました。手動で選択してください。', 'error');
|
||||
showToast?.(t('members.toast.copyFailed'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="space-invite-section" className="rounded-md border border-hairline bg-surface/40 p-4 space-y-3 max-w-md">
|
||||
<div>
|
||||
<h3 className="text-[13px] font-semibold text-slate-900">招待リンク</h3>
|
||||
<h3 className="text-[13px] font-semibold text-slate-900">{t('members.invite.title')}</h3>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed mt-0.5">
|
||||
リンクを知っている人が、ログインのうえ選んだ役割でこのワークスペースに参加できます。組織に所属していない相手も招待できます。
|
||||
{t('members.invite.description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-[13px] text-slate-400">読み込み中…</div>
|
||||
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
|
||||
) : active ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -296,13 +288,15 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
onClick={handleCopy}
|
||||
className="h-8 shrink-0 rounded-md bg-accent px-3 text-xs font-semibold text-accent-fg hover:bg-accent-deep"
|
||||
>
|
||||
{copied ? 'コピー済' : 'コピー'}
|
||||
{copied ? t('members.invite.copied') : t('members.invite.copy')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-2xs text-slate-500">
|
||||
<span>
|
||||
役割: {INVITE_ROLE_LABEL[invite.role]}
|
||||
{invite.expiresAt ? ` ・ 期限: ${new Date(invite.expiresAt.replace(' ', 'T') + 'Z').toLocaleDateString()}` : ' ・ 無期限'}
|
||||
{t('members.invite.roleLabel', { role: t(`members.role.${invite.role}`) })}
|
||||
{invite.expiresAt
|
||||
? t('members.invite.expiresAt', { date: new Date(invite.expiresAt.replace(' ', 'T') + 'Z').toLocaleDateString() })
|
||||
: t('members.invite.noExpiry')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -312,7 +306,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
disabled={createMut.isPending}
|
||||
className="text-slate-600 underline hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
再生成
|
||||
{t('members.invite.regenerate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -321,7 +315,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
disabled={revokeMut.isPending}
|
||||
className="text-red-600 underline hover:text-red-800 disabled:opacity-50"
|
||||
>
|
||||
無効化
|
||||
{t('members.invite.revoke')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -335,8 +329,8 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
onChange={(e) => setRole(e.target.value as SpaceInviteRole)}
|
||||
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
<option value="viewer">{INVITE_ROLE_LABEL.viewer}</option>
|
||||
<option value="editor">{INVITE_ROLE_LABEL.editor}</option>
|
||||
<option value="viewer">{t('members.role.viewer')}</option>
|
||||
<option value="editor">{t('members.role.editor')}</option>
|
||||
</select>
|
||||
<select
|
||||
data-testid="space-invite-expiry"
|
||||
@@ -345,7 +339,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
{EXPIRY_OPTIONS.map((o, i) => (
|
||||
<option key={i} value={i}>{o.label}</option>
|
||||
<option key={i} value={i}>{t(o.labelKey)}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
@@ -355,7 +349,7 @@ function InviteLinkSection({ spaceId, showToast }: { spaceId: string; showToast?
|
||||
disabled={createMut.isPending}
|
||||
className="rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg hover:bg-accent-deep disabled:opacity-50"
|
||||
>
|
||||
{createMut.isPending ? '作成中…' : '招待リンクを作成'}
|
||||
{createMut.isPending ? t('members.invite.creating') : t('members.invite.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -377,6 +371,7 @@ function InvitePicker({
|
||||
onCancel: () => void;
|
||||
onAdd: (userId: string, role: SpaceMemberRole) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['pickable-users', spaceId],
|
||||
queryFn: fetchPickableUsers,
|
||||
@@ -409,14 +404,14 @@ function InvitePicker({
|
||||
data-testid="space-member-picker"
|
||||
className="rounded-md border border-hairline bg-surface/50 p-4 text-[13px] text-slate-500"
|
||||
>
|
||||
認証を有効化するとワークスペースを共有できます。
|
||||
{t('members.picker.authRequired')}
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs text-slate-600 hover:text-slate-800 underline"
|
||||
>
|
||||
閉じる
|
||||
{t('members.picker.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -432,23 +427,23 @@ function InvitePicker({
|
||||
className="rounded-md border border-hairline bg-surface/50 p-4 space-y-3 max-w-md"
|
||||
>
|
||||
<p data-testid="space-member-picker-org-note" className="text-2xs text-slate-500 leading-relaxed">
|
||||
同じ組織のメンバーのみ表示されます。
|
||||
{t('members.picker.orgNote')}
|
||||
</p>
|
||||
<input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="名前・メールで検索"
|
||||
placeholder={t('members.picker.searchPlaceholder')}
|
||||
className="h-8 w-full rounded-md border border-hairline px-2 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-[13px] text-slate-400">読み込み中…</div>
|
||||
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
|
||||
) : candidates.length === 0 ? (
|
||||
<div className="text-[13px] text-slate-400">
|
||||
{allAlreadyAdded
|
||||
? '同じ組織のメンバーは全員このワークスペースに追加済みです。'
|
||||
: '追加できるユーザーがいません。同じ組織のメンバーだけが候補に表示されます。'}
|
||||
? t('members.picker.allAdded')
|
||||
: t('members.picker.noCandidates')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
@@ -480,8 +475,8 @@ function InvitePicker({
|
||||
onChange={(e) => setRole(e.target.value as SpaceMemberRole)}
|
||||
className="rounded border border-hairline px-2 py-1 text-xs bg-surface text-slate-700 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
>
|
||||
<option value="editor">{ROLE_LABEL.editor}</option>
|
||||
<option value="viewer">{ROLE_LABEL.viewer}</option>
|
||||
<option value="editor">{t('members.role.editor')}</option>
|
||||
<option value="viewer">{t('members.role.viewer')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
@@ -489,14 +484,14 @@ function InvitePicker({
|
||||
onClick={() => selectedId && onAdd(selectedId, role)}
|
||||
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isPending ? '追加中…' : '追加'}
|
||||
{isPending ? t('members.picker.adding') : t('members.picker.add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-1.5 rounded-md text-xs text-slate-700 border border-hairline hover:bg-surface transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
{t('common:cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component test for SpaceRail — the workspace switcher rail. Mocks the data
|
||||
* hooks (useSpaces, useLocalTaskList) and useAuthState so the real grouping,
|
||||
* "自分" badge, running-count badge, selection, empty/loading/error states and
|
||||
* the create dialog toggle are exercised. SpaceFormDialog is stubbed so opening
|
||||
* it doesn't pull in the full create-dialog tree.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import type { Space } from '../../api';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
// --- Mocks (declared before importing the component under test) -------------
|
||||
const useSpacesMock = vi.fn();
|
||||
const useTaskListMock = vi.fn();
|
||||
const useAuthStateMock = vi.fn();
|
||||
|
||||
vi.mock('../../hooks/useSpaces', () => ({ useSpaces: () => useSpacesMock() }));
|
||||
vi.mock('../../hooks/useTaskList', () => ({ useLocalTaskList: () => useTaskListMock() }));
|
||||
vi.mock('../../App', () => ({ useAuthState: () => useAuthStateMock() }));
|
||||
vi.mock('./SpaceFormDialog', () => ({
|
||||
SpaceFormDialog: ({ onSaved }: { onSaved: (id: string) => void }) => (
|
||||
<div data-testid="space-form-dialog">
|
||||
<button onClick={() => onSaved('new-space')}>save</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { SpaceRail } from './SpaceRail';
|
||||
|
||||
function space(p: Partial<Space> & Pick<Space, 'id' | 'kind' | 'title'>): Space {
|
||||
return {
|
||||
description: '',
|
||||
ownerId: null,
|
||||
visibility: 'private',
|
||||
visibilityScopeOrgId: null,
|
||||
status: 'open',
|
||||
brandColor: null,
|
||||
workspaceDir: null,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
...p,
|
||||
} as Space;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Rail labels (loading/error/empty/running-count/"自分" badge) now route through
|
||||
// i18next. Pin the language so text assertions are deterministic.
|
||||
void i18n.changeLanguage('ja');
|
||||
useSpacesMock.mockReset();
|
||||
useTaskListMock.mockReset();
|
||||
useAuthStateMock.mockReset();
|
||||
useTaskListMock.mockReturnValue({ data: [] });
|
||||
useAuthStateMock.mockReturnValue({ mode: 'disabled' });
|
||||
});
|
||||
|
||||
describe('SpaceRail', () => {
|
||||
it('shows the loading state', () => {
|
||||
useSpacesMock.mockReturnValue({ data: undefined, isLoading: true, isError: false });
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
expect(screen.getByText('読み込み中…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the error state', () => {
|
||||
useSpacesMock.mockReturnValue({ data: undefined, isLoading: false, isError: true });
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
expect(screen.getByText('ワークスペースを取得できませんでした')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no spaces', () => {
|
||||
useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false });
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
expect(
|
||||
screen.getByText('ワークスペースがありません。「+ 新規」から作成してください。'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups personal and case spaces under their headers', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [
|
||||
space({ id: 'p1', kind: 'personal', title: 'My Workspace' }),
|
||||
space({ id: 'c1', kind: 'case', title: 'Project A' }),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
const groups = screen.getAllByTestId('space-group');
|
||||
expect(groups.map((g) => g.getAttribute('data-group'))).toEqual([
|
||||
'統合スペース',
|
||||
'個別スペース',
|
||||
]);
|
||||
expect(screen.getByText('My Workspace')).toBeInTheDocument();
|
||||
expect(screen.getByText('Project A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSelect with the space id when a row is clicked', () => {
|
||||
const onSelect = vi.fn();
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={onSelect} />);
|
||||
fireEvent.click(screen.getByText('Project A'));
|
||||
expect(onSelect).toHaveBeenCalledWith('c1');
|
||||
});
|
||||
|
||||
it('renders a running-count badge when a space has running tasks', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
useTaskListMock.mockReturnValue({
|
||||
data: [
|
||||
{ spaceId: 'c1', latestJob: { status: 'running' } },
|
||||
{ spaceId: 'c1', latestJob: { status: 'succeeded' } },
|
||||
],
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
const badge = screen.getByTestId('space-running-count');
|
||||
expect(badge).toHaveTextContent('1 実行中');
|
||||
});
|
||||
|
||||
it('shows the "自分" badge only when other users\' spaces are present (admin view)', () => {
|
||||
useAuthStateMock.mockReturnValue({ mode: 'authenticated', user: { id: 'me' } });
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [
|
||||
space({ id: 'c1', kind: 'case', title: 'Mine', ownerId: 'me' }),
|
||||
space({ id: 'c2', kind: 'case', title: 'Theirs', ownerId: 'other' }),
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
const badges = screen.getAllByTestId('space-mine-badge');
|
||||
expect(badges).toHaveLength(1);
|
||||
// Badge lives inside the owner's row.
|
||||
const mineRow = screen.getByText('Mine').closest('[data-testid="space-row"]');
|
||||
expect(mineRow).toHaveAttribute('data-space-mine', '1');
|
||||
});
|
||||
|
||||
it('hides the "自分" badge when all spaces belong to the viewer', () => {
|
||||
useAuthStateMock.mockReturnValue({ mode: 'authenticated', user: { id: 'me' } });
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Mine', ownerId: 'me' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
expect(screen.queryByTestId('space-mine-badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the create dialog and selects the new space on save', () => {
|
||||
const onSelect = vi.fn();
|
||||
useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false });
|
||||
render(<SpaceRail onSelect={onSelect} />);
|
||||
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('create-space-btn'));
|
||||
expect(screen.getByTestId('space-form-dialog')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('save'));
|
||||
expect(onSelect).toHaveBeenCalledWith('new-space');
|
||||
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,69 +1,116 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthState } from '../../App';
|
||||
import { useSpaces } from '../../hooks/useSpaces';
|
||||
import { useLocalTaskList } from '../../hooks/useTaskList';
|
||||
import { sortSpacesForRail } from '../../lib/spaceSort';
|
||||
import { countRunningTasksForSpace } from '../../lib/spaceTasks';
|
||||
import { statusTone } from '../../lib/utils';
|
||||
import type { Space } from '../../api';
|
||||
import { CreateSpaceDialog } from './CreateSpaceDialog';
|
||||
import { SpaceFormDialog } from './SpaceFormDialog';
|
||||
|
||||
interface SpaceRailProps {
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const VIS_LABEL: Record<Space['visibility'], string> = {
|
||||
private: 'private',
|
||||
// 可視性ラベルは private を出さない(既定値でノイズになるため)。org/public のみ
|
||||
// 意味があるので表示する。
|
||||
const VIS_LABEL: Partial<Record<Space['visibility'], string>> = {
|
||||
org: 'org',
|
||||
public: 'public',
|
||||
};
|
||||
|
||||
// グループの色帯。統合スペース(個人=作業が集まる中心)はブランド色、個別スペース
|
||||
// (案件=プロジェクトごとに分かれる)は中立色で、左端の帯で一目で区別する。
|
||||
const GROUP_BAND_INTEGRATED = 'var(--brand-primary)';
|
||||
const GROUP_BAND_INDIVIDUAL = '#94a3b8'; // slate-400
|
||||
|
||||
interface SpaceGroupDef {
|
||||
/** Stable key used for data-group (test selector); decoupled from the display label. */
|
||||
key: string;
|
||||
label: string;
|
||||
band: string;
|
||||
spaces: Space[];
|
||||
}
|
||||
|
||||
export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const { data: spaces, isLoading, isError } = useSpaces();
|
||||
// 実行中件数の算出元。リスト API はスペースで絞らないので全件を保持しており、
|
||||
// FAST ポーリングで自動更新される。スペースごとにクライアント側で数える。
|
||||
const { data: tasks } = useLocalTaskList();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const auth = useAuthState();
|
||||
const myUserId = auth.mode === 'authenticated' ? auth.user.id : null;
|
||||
|
||||
const sorted = useMemo(() => sortSpacesForRail(spaces ?? []), [spaces]);
|
||||
const personal = sorted.filter(s => s.kind === 'personal');
|
||||
const cases = sorted.filter(s => s.kind !== 'personal');
|
||||
// 他ユーザー所有のスペースが一覧に混在するとき(=admin が全ユーザーのスペースを
|
||||
// 見ている場合)だけ「自分」バッジを出す。単一ユーザーの一覧では全部自分なので
|
||||
// ノイズにしかならず、出さない(issue #003)。
|
||||
const hasOthersSpaces = useMemo(
|
||||
() => myUserId != null && sorted.some(s => s.ownerId != null && s.ownerId !== myUserId),
|
||||
[sorted, myUserId],
|
||||
);
|
||||
const groups = useMemo<SpaceGroupDef[]>(() => {
|
||||
const personal = sorted.filter(s => s.kind === 'personal');
|
||||
const cases = sorted.filter(s => s.kind !== 'personal');
|
||||
const out: SpaceGroupDef[] = [];
|
||||
if (personal.length > 0) out.push({ key: '統合スペース', label: t('rail.group.integrated'), band: GROUP_BAND_INTEGRATED, spaces: personal });
|
||||
if (cases.length > 0) out.push({ key: '個別スペース', label: t('rail.group.individual'), band: GROUP_BAND_INDIVIDUAL, spaces: cases });
|
||||
return out;
|
||||
}, [sorted, t]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" data-testid="space-rail">
|
||||
<div className="flex items-center justify-between border-b border-hairline px-3 py-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">ワークスペース</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-500">{t('rail.heading')}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-space-btn"
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="rounded-md border border-hairline px-2 py-1 text-xs font-semibold text-slate-700 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
+ 新規
|
||||
{t('rail.new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{isLoading && <p className="px-1 py-2 text-xs text-slate-500">読み込み中…</p>}
|
||||
{isError && <p className="px-1 py-2 text-xs text-red-600">ワークスペースを取得できませんでした</p>}
|
||||
{isLoading && <p className="px-1 py-2 text-xs text-slate-500">{t('common:loading')}</p>}
|
||||
{isError && <p className="px-1 py-2 text-xs text-red-600">{t('rail.fetchError')}</p>}
|
||||
|
||||
{personal.length > 0 && (
|
||||
<div className="mb-1 px-1 pt-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">個人</div>
|
||||
)}
|
||||
{personal.map(s => (
|
||||
<SpaceRow key={s.id} space={s} active={s.id === selectedId} onSelect={onSelect} />
|
||||
))}
|
||||
|
||||
{cases.length > 0 && (
|
||||
<div className="mb-1 mt-2 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">案件</div>
|
||||
)}
|
||||
{cases.map(s => (
|
||||
<SpaceRow key={s.id} space={s} active={s.id === selectedId} onSelect={onSelect} />
|
||||
{groups.map((g, i) => (
|
||||
<section
|
||||
key={g.key}
|
||||
data-testid="space-group"
|
||||
data-group={g.key}
|
||||
className={`border-l-2 pl-2 ${i > 0 ? 'mt-2 border-t border-hairline pt-2' : ''}`}
|
||||
style={{ borderLeftColor: g.band }}
|
||||
>
|
||||
<div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">{g.label}</div>
|
||||
{g.spaces.map(s => (
|
||||
<SpaceRow
|
||||
key={s.id}
|
||||
space={s}
|
||||
active={s.id === selectedId}
|
||||
onSelect={onSelect}
|
||||
runningCount={countRunningTasksForSpace(tasks ?? [], s)}
|
||||
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{!isLoading && !isError && sorted.length === 0 && (
|
||||
<p className="px-1 py-2 text-xs text-slate-500">ワークスペースがありません。「+ 新規」から作成してください。</p>
|
||||
<p className="px-1 py-2 text-xs text-slate-500">{t('rail.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateSpaceDialog
|
||||
<SpaceFormDialog
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={(id) => {
|
||||
onSaved={(id) => {
|
||||
setShowCreate(false);
|
||||
onSelect(id);
|
||||
}}
|
||||
@@ -77,18 +124,26 @@ function SpaceRow({
|
||||
space,
|
||||
active,
|
||||
onSelect,
|
||||
runningCount,
|
||||
mine,
|
||||
}: {
|
||||
space: Space;
|
||||
active: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
runningCount: number;
|
||||
/** 他ユーザーのスペースが混在する一覧で、これが閲覧者自身の所有なら true。 */
|
||||
mine?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const dot = space.brandColor ?? 'var(--brand-primary)';
|
||||
const runningStyle = statusTone('running');
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-row"
|
||||
data-space-kind={space.kind}
|
||||
data-space-id={space.id}
|
||||
data-space-mine={mine ? '1' : undefined}
|
||||
onClick={() => onSelect(space.id)}
|
||||
className={`mb-0.5 flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left transition-colors ${
|
||||
active
|
||||
@@ -102,9 +157,31 @@ function SpaceRow({
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1 truncate text-[12.5px] font-semibold text-slate-800">{space.title}</span>
|
||||
<span className="font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
|
||||
{VIS_LABEL[space.visibility]}
|
||||
</span>
|
||||
{mine && (
|
||||
<span
|
||||
data-testid="space-mine-badge"
|
||||
className="shrink-0 rounded-full bg-[var(--brand-primary-soft)] px-1.5 py-0.5 text-[9px] font-bold tracking-wide text-[var(--brand-primary)]"
|
||||
title={t('rail.mineTitle')}
|
||||
>
|
||||
{t('rail.mine')}
|
||||
</span>
|
||||
)}
|
||||
{VIS_LABEL[space.visibility] && (
|
||||
<span className="shrink-0 font-mono text-[9px] font-bold uppercase tracking-wide text-slate-400">
|
||||
{VIS_LABEL[space.visibility]}
|
||||
</span>
|
||||
)}
|
||||
{runningCount > 0 && (
|
||||
<span
|
||||
data-testid="space-running-count"
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[9px] font-bold tabular-nums"
|
||||
style={{ background: runningStyle.bg, color: runningStyle.fg }}
|
||||
title={t('rail.runningTitle', { count: runningCount })}
|
||||
aria-label={t('rail.runningAria', { count: runningCount })}
|
||||
>
|
||||
● {t('rail.running', { count: runningCount })}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AgentsMdPanel } from '../userfolder/AgentsMdPanel';
|
||||
import { MemoryPanel } from '../userfolder/MemoryPanel';
|
||||
@@ -20,6 +21,7 @@ import { McpPanel } from '../userfolder/McpPanel';
|
||||
import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
|
||||
import { SpaceMembersPanel } from './SpaceMembersPanel';
|
||||
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
|
||||
import { SpaceToolSettings } from './SpaceToolSettings';
|
||||
import { PieceEditor } from '../settings/PieceEditor';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { splitPieces } from '../../lib/splitPieces';
|
||||
@@ -28,27 +30,29 @@ import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members';
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools';
|
||||
|
||||
const SECTIONS: { id: SettingsSection; label: string; testid: string }[] = [
|
||||
{ id: 'agents', label: 'AGENTS.md', testid: 'space-settings-nav-agents' },
|
||||
{ id: 'memory', label: 'メモリ', testid: 'space-settings-nav-memory' },
|
||||
{ id: 'pieces', label: 'Pieces', testid: 'space-settings-nav-pieces' },
|
||||
{ id: 'skills', label: 'スキル', testid: 'space-settings-nav-skills' },
|
||||
{ id: 'mcp', label: 'MCP', testid: 'space-settings-nav-mcp' },
|
||||
{ id: 'ssh', label: 'SSH', testid: 'space-settings-nav-ssh' },
|
||||
{ id: 'browser', label: 'ブラウザ', testid: 'space-settings-nav-browser' },
|
||||
{ id: 'members', label: 'メンバー', testid: 'space-settings-nav-members' },
|
||||
const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
|
||||
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' },
|
||||
{ id: 'memory', labelKey: 'settings.nav.memory', testid: 'space-settings-nav-memory' },
|
||||
{ id: 'pieces', labelKey: 'settings.nav.pieces', testid: 'space-settings-nav-pieces' },
|
||||
{ id: 'skills', labelKey: 'settings.nav.skills', testid: 'space-settings-nav-skills' },
|
||||
{ id: 'mcp', labelKey: 'settings.nav.mcp', testid: 'space-settings-nav-mcp' },
|
||||
{ id: 'ssh', labelKey: 'settings.nav.ssh', testid: 'space-settings-nav-ssh' },
|
||||
{ id: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' },
|
||||
{ id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' },
|
||||
{ id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' },
|
||||
];
|
||||
|
||||
export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const [section, setSection] = useState<SettingsSection>('agents');
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col md:flex-row md:gap-3">
|
||||
{/* Sub-nav: モバイルは横スクロールのセグメント、md+ は左の縦リスト。 */}
|
||||
<nav
|
||||
aria-label="ワークスペース設定"
|
||||
aria-label={t('settings.navLabel')}
|
||||
className="flex shrink-0 gap-1 overflow-x-auto border-b border-hairline pb-2 md:w-44 md:flex-col md:overflow-x-visible md:border-b-0 md:border-r md:pb-0 md:pr-3"
|
||||
>
|
||||
{SECTIONS.map(s => {
|
||||
@@ -65,7 +69,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
|
||||
: 'text-slate-600 hover:bg-surface hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
{t(s.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -80,6 +84,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
|
||||
{section === 'mcp' && <McpPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'ssh' && <SshConnectionsPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +97,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
|
||||
* `PieceEditor` の再利用で構成する。選択はローカル state。
|
||||
*/
|
||||
function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const isAdmin = auth.mode === 'authenticated' && auth.user.role === 'admin';
|
||||
const qc = useQueryClient();
|
||||
@@ -129,7 +135,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
setNewName('');
|
||||
setSelected({ name, source });
|
||||
} catch (e) {
|
||||
const msg = `Piece の作成に失敗しました: ${e instanceof Error ? e.message : String(e)}`;
|
||||
const msg = t('settings.pieces.createFailed', { msg: e instanceof Error ? e.message : String(e) });
|
||||
if (showToast) showToast(msg, 'error');
|
||||
else console.error(msg);
|
||||
} finally {
|
||||
@@ -158,16 +164,16 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
<div className="flex h-full min-h-0">
|
||||
{/* 左: 一覧 */}
|
||||
<div className="w-48 shrink-0 overflow-y-auto border-r border-hairline p-2">
|
||||
<div className="mb-1 px-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">Default</div>
|
||||
{defaults.length === 0 && <div className="px-2 text-xs text-slate-400">(なし)</div>}
|
||||
<div className="mb-1 px-2 text-2xs font-semibold uppercase tracking-wide text-slate-500">{t('settings.pieces.default')}</div>
|
||||
{defaults.length === 0 && <div className="px-2 text-xs text-slate-400">{t('settings.pieces.none')}</div>}
|
||||
{defaults.map(p => renderRow(p, true))}
|
||||
|
||||
<div className="mb-1 mt-3 flex items-center justify-between px-2">
|
||||
<span className="text-2xs font-semibold uppercase tracking-wide text-slate-500">Custom</span>
|
||||
<span className="text-2xs font-semibold uppercase tracking-wide text-slate-500">{t('settings.pieces.custom')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreating(true)}
|
||||
title="新しい Piece"
|
||||
title={t('settings.pieces.new')}
|
||||
className="flex h-5 w-5 items-center justify-center rounded text-slate-500 hover:bg-surface-2 hover:text-slate-900 text-sm leading-none transition-colors"
|
||||
>
|
||||
+
|
||||
@@ -189,7 +195,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{customs.length === 0 && !isCreating && <div className="px-2 text-xs text-slate-400">(なし)</div>}
|
||||
{customs.length === 0 && !isCreating && <div className="px-2 text-xs text-slate-400">{t('settings.pieces.none')}</div>}
|
||||
{customs.map(p => renderRow(p, false))}
|
||||
</div>
|
||||
|
||||
@@ -204,7 +210,7 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
onDeleted={() => setSelected(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-slate-400">左から Piece を選んでください。</div>
|
||||
<div className="text-sm text-slate-400">{t('settings.pieces.selectHint')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for SpaceToolSettings (tool-policy / sensitive-tools UI).
|
||||
*
|
||||
* This is the PR #653 bug class: a sensitive *tool* (Bash) is delivered in a
|
||||
* separate `sensitiveTools` array, NOT in `categories`. The save patch
|
||||
* (buildPolicyPatch) must fold Bash's toggle into `enabledSensitive`, alongside
|
||||
* sensitive *categories* (ssh / browser). These tests render the real component
|
||||
* and assert that toggling Bash persists through to the PUT payload — the exact
|
||||
* regression that the earlier "categories-only" patch builder dropped.
|
||||
*
|
||||
* api + App.useAuthState are mocked so no real network and canManage=true.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
const { fetchSpaceToolPolicyMock, updateSpaceToolPolicyMock, fetchSpaceMembersMock } = vi.hoisted(() => ({
|
||||
fetchSpaceToolPolicyMock: vi.fn(),
|
||||
updateSpaceToolPolicyMock: vi.fn(),
|
||||
fetchSpaceMembersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
fetchSpaceToolPolicy: fetchSpaceToolPolicyMock,
|
||||
updateSpaceToolPolicy: updateSpaceToolPolicyMock,
|
||||
fetchSpaceMembers: fetchSpaceMembersMock,
|
||||
}));
|
||||
|
||||
// auth.mode === 'disabled' => canManage = true (no owner check needed).
|
||||
vi.mock('../../App', () => ({
|
||||
useAuthState: () => ({ mode: 'disabled' as const }),
|
||||
}));
|
||||
|
||||
import { SpaceToolSettings } from './SpaceToolSettings';
|
||||
|
||||
const POLICY = {
|
||||
policy: { disabledSafe: [], enabledSensitive: [] },
|
||||
categories: [
|
||||
{ name: 'web', sensitive: false, enabled: true },
|
||||
{ name: 'office', sensitive: false, enabled: true },
|
||||
{ name: 'ssh', sensitive: true, enabled: false },
|
||||
{ name: 'browser', sensitive: true, enabled: false },
|
||||
],
|
||||
// Bash arrives as a SEPARATE sensitive tool, not a category — the PR #653 trap.
|
||||
sensitiveTools: [{ name: 'Bash', enabled: false }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Labels like the Save button now route through i18next. Pin the language so
|
||||
// assertions on rendered text are deterministic regardless of detector state.
|
||||
void i18n.changeLanguage('ja');
|
||||
fetchSpaceMembersMock.mockResolvedValue([]);
|
||||
fetchSpaceToolPolicyMock.mockResolvedValue(structuredClone(POLICY));
|
||||
updateSpaceToolPolicyMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
async function renderLoaded() {
|
||||
renderWithProviders(<SpaceToolSettings spaceId="space-1" />);
|
||||
// Wait for the policy query to resolve and the Bash row to render.
|
||||
await waitFor(() => expect(screen.getByText('Bash')).toBeInTheDocument());
|
||||
}
|
||||
|
||||
/** Find the role=switch toggle for a row identified by its label text. */
|
||||
function switchFor(label: string): HTMLElement {
|
||||
// Walk up from the label until we reach an ancestor that also contains a switch.
|
||||
let el: HTMLElement | null = screen.getByText(label);
|
||||
while (el && el.parentElement) {
|
||||
el = el.parentElement;
|
||||
const sw = within(el).queryByRole('switch');
|
||||
if (sw) return sw;
|
||||
}
|
||||
throw new Error(`No switch found for row "${label}"`);
|
||||
}
|
||||
|
||||
describe('SpaceToolSettings', () => {
|
||||
it('renders safe categories, sensitive categories, and the separate Bash tool', async () => {
|
||||
await renderLoaded();
|
||||
expect(screen.getByText('web')).toBeInTheDocument();
|
||||
expect(screen.getByText('ssh')).toBeInTheDocument();
|
||||
expect(screen.getByText('browser')).toBeInTheDocument();
|
||||
// Bash is the separately-delivered sensitive tool.
|
||||
expect(screen.getByText('Bash')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Save is disabled until a toggle changes', async () => {
|
||||
await renderLoaded();
|
||||
const save = screen.getByRole('button', { name: '保存' });
|
||||
expect(save).toBeDisabled();
|
||||
});
|
||||
|
||||
it('persists the Bash toggle into enabledSensitive (PR #653 regression)', async () => {
|
||||
await renderLoaded();
|
||||
// Each ToggleRow is a role="switch". Find Bash's switch by walking from its label.
|
||||
await userEvent.click(switchFor('Bash'));
|
||||
|
||||
const save = screen.getByRole('button', { name: '保存' });
|
||||
expect(save).not.toBeDisabled();
|
||||
await userEvent.click(save);
|
||||
|
||||
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
|
||||
const [spaceId, patch] = updateSpaceToolPolicyMock.mock.calls[0];
|
||||
expect(spaceId).toBe('space-1');
|
||||
// The critical assertion: Bash must land in enabledSensitive, NOT be dropped.
|
||||
expect(patch.enabledSensitive).toContain('Bash');
|
||||
// No safe category was disabled.
|
||||
expect(patch.disabledSafe).toEqual([]);
|
||||
});
|
||||
|
||||
it('persists a sensitive CATEGORY toggle (ssh) alongside the tool system', async () => {
|
||||
await renderLoaded();
|
||||
await userEvent.click(switchFor('ssh'));
|
||||
await userEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
|
||||
const [, patch] = updateSpaceToolPolicyMock.mock.calls[0];
|
||||
expect(patch.enabledSensitive).toContain('ssh');
|
||||
expect(patch.enabledSensitive).not.toContain('Bash'); // untouched stays off
|
||||
});
|
||||
|
||||
it('disabling a safe category lands in disabledSafe', async () => {
|
||||
await renderLoaded();
|
||||
await userEvent.click(switchFor('web')); // turn OFF (was on)
|
||||
await userEvent.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => expect(updateSpaceToolPolicyMock).toHaveBeenCalledTimes(1));
|
||||
const [, patch] = updateSpaceToolPolicyMock.mock.calls[0];
|
||||
expect(patch.disabledSafe).toContain('web');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* SpaceToolSettings.tsx — ワークスペースごとのツールポリシー設定 UI
|
||||
*
|
||||
* - 安全カテゴリ(sensitive=false): デフォルト ON のトグル群
|
||||
* - センシティブカテゴリ(sensitive=true)+ Bash: デフォルト OFF のトグル群。
|
||||
* 各項目に 1 行のリスク説明を表示。
|
||||
* - カテゴリ一覧は API から動的取得(ハードコードなし)。
|
||||
* - オーナーのみ編集可(canManage 判定は SpaceMembersPanel と同じシグナル)。
|
||||
* - 保存は PUT /api/local/spaces/:id/tool-policy(react-query mutation)。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpaceToolPolicy,
|
||||
fetchSpaceMembers,
|
||||
updateSpaceToolPolicy,
|
||||
type ToolCategory,
|
||||
} from '../../api';
|
||||
import { useAuthState } from '../../App';
|
||||
import { splitCategories, buildPolicyPatch, countEnabledCategories } from '../../lib/toolPolicy';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
/** センシティブカテゴリ・ツールに表示する 1 行のリスク説明の翻訳キー。 */
|
||||
const SENSITIVE_NOTE_KEYS: Record<string, string> = {
|
||||
ssh: 'tools.sensitiveNote.ssh',
|
||||
browser: 'tools.sensitiveNote.browser',
|
||||
Bash: 'tools.sensitiveNote.Bash',
|
||||
};
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
interface ToggleRowProps {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
note?: string;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
onChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function ToggleRow({ name, enabled, note, disabled, disabledReason, onChange }: ToggleRowProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2.5 border-b border-hairline last:border-b-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium text-slate-900">{name}</span>
|
||||
{disabled && disabledReason && (
|
||||
<span className="text-2xs text-slate-400">({disabledReason})</span>
|
||||
)}
|
||||
</div>
|
||||
{note && (
|
||||
<p className="text-2xs text-slate-500 leading-relaxed mt-0.5">{note}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!enabled)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent-ring focus:ring-offset-1 disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
enabled ? 'bg-accent' : 'bg-slate-300 dark:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${
|
||||
enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpaceToolSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const noteFor = (name: string): string | undefined => {
|
||||
const key = SENSITIVE_NOTE_KEYS[name];
|
||||
return key ? t(key) : undefined;
|
||||
};
|
||||
|
||||
// メンバー一覧から canManage を判定(SpaceMembersPanel と同じロジック)
|
||||
const { data: members } = useQuery({
|
||||
queryKey: ['space-members', spaceId],
|
||||
queryFn: () => fetchSpaceMembers(spaceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const ownerRow = (members ?? []).find(m => m.isOwner);
|
||||
const canManage =
|
||||
auth.mode === 'disabled' ||
|
||||
(auth.mode === 'authenticated' &&
|
||||
(auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id)));
|
||||
|
||||
// ツールポリシー取得
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['space-tool-policy', spaceId],
|
||||
queryFn: () => fetchSpaceToolPolicy(spaceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// UI ローカルのトグル状態(未保存の変更を保持)
|
||||
const [toggledSafe, setToggledSafe] = useState<Record<string, boolean>>({});
|
||||
const [toggledSens, setToggledSens] = useState<Record<string, boolean>>({});
|
||||
const [savedState, setSavedState] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['space-tool-policy', spaceId] });
|
||||
};
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (patch: { disabledSafe: string[]; enabledSensitive: string[] }) =>
|
||||
updateSpaceToolPolicy(spaceId, patch),
|
||||
onSuccess: () => {
|
||||
setSavedState('saved');
|
||||
setToggledSafe({});
|
||||
setToggledSens({});
|
||||
invalidate();
|
||||
setTimeout(() => setSavedState('idle'), 2000);
|
||||
},
|
||||
onError: (e) => {
|
||||
setSavedState('error');
|
||||
showToast?.(t('tools.toast.saveFailed', { msg: errMsg(e) }), 'error');
|
||||
setTimeout(() => setSavedState('idle'), 3000);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (!data) return;
|
||||
const patch = buildPolicyPatch(data.categories, toggledSafe, toggledSens, data.sensitiveTools ?? []);
|
||||
saveMut.mutate(patch);
|
||||
};
|
||||
|
||||
const hasPendingChanges = Object.keys(toggledSafe).length > 0 || Object.keys(toggledSens).length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8">
|
||||
<div className="text-[13px] text-red-600">
|
||||
{t('tools.fetchError', { msg: errMsg(error) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { safe, sensitive } = splitCategories(data.categories);
|
||||
const enabledCount = countEnabledCategories(data.categories, toggledSafe, toggledSens);
|
||||
const readonlyReason = t('tools.readonlyReason');
|
||||
|
||||
// センシティブグループ: カテゴリ + Bash(sensitiveTools から取得)
|
||||
// sensitiveTools は Bash など個別ツールで sensitive=true なもの
|
||||
const sensitiveBash = data.sensitiveTools ?? [];
|
||||
|
||||
const resolveEnabled = (cat: ToolCategory, map: Record<string, boolean>) => {
|
||||
return Object.prototype.hasOwnProperty.call(map, cat.name) ? map[cat.name] : cat.enabled;
|
||||
};
|
||||
|
||||
const resolveSensToolEnabled = (toolName: string, apiEnabled: boolean) => {
|
||||
return Object.prototype.hasOwnProperty.call(toggledSens, toolName)
|
||||
? toggledSens[toolName]
|
||||
: apiEnabled;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto" data-testid="space-tool-settings">
|
||||
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
|
||||
{/* ヘッダー */}
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('tools.heading')}</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
{t('tools.intro')}
|
||||
</p>
|
||||
<p className="text-[13px] text-slate-500 mt-1">
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="tools.enabledCount"
|
||||
values={{ count: enabledCount }}
|
||||
components={{ strong: <span className="font-semibold text-slate-700" /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 安全カテゴリ(デフォルト ON) */}
|
||||
<section>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
|
||||
{t('tools.standardCategories')}
|
||||
</h3>
|
||||
{safe.length === 0 ? (
|
||||
<p className="text-[13px] text-slate-400">{t('tools.noStandardCategories')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden px-3">
|
||||
{safe.map(cat => (
|
||||
<ToggleRow
|
||||
key={cat.name}
|
||||
name={cat.name}
|
||||
enabled={resolveEnabled(cat, toggledSafe)}
|
||||
disabled={!canManage || saveMut.isPending}
|
||||
disabledReason={!canManage ? readonlyReason : undefined}
|
||||
onChange={v => setToggledSafe(prev => ({ ...prev, [cat.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* センシティブカテゴリ(デフォルト OFF) */}
|
||||
<section>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-1">
|
||||
{t('tools.sensitiveTools')}
|
||||
</h3>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed mb-2">
|
||||
{t('tools.sensitiveWarning')}
|
||||
</p>
|
||||
{sensitive.length === 0 && sensitiveBash.length === 0 ? (
|
||||
<p className="text-[13px] text-slate-400">{t('tools.noSensitiveCategories')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-hairline bg-amber-50/40 dark:bg-amber-900/10 divide-y divide-hairline overflow-hidden px-3">
|
||||
{/* センシティブカテゴリ(ssh / browser 等) */}
|
||||
{sensitive.map(cat => (
|
||||
<ToggleRow
|
||||
key={cat.name}
|
||||
name={cat.name}
|
||||
enabled={resolveEnabled(cat, toggledSens)}
|
||||
note={noteFor(cat.name)}
|
||||
disabled={!canManage || saveMut.isPending}
|
||||
disabledReason={!canManage ? readonlyReason : undefined}
|
||||
onChange={v => setToggledSens(prev => ({ ...prev, [cat.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
{/* 個別センシティブツール(Bash 等) */}
|
||||
{sensitiveBash.map(tool => (
|
||||
<ToggleRow
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
enabled={resolveSensToolEnabled(tool.name, tool.enabled)}
|
||||
note={noteFor(tool.name)}
|
||||
disabled={!canManage || saveMut.isPending}
|
||||
disabledReason={!canManage ? readonlyReason : undefined}
|
||||
onChange={v => setToggledSens(prev => ({ ...prev, [tool.name]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 保存ボタン */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canManage || saveMut.isPending || !hasPendingChanges}
|
||||
className="px-4 py-1.5 rounded-md text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saveMut.isPending ? t('tools.saving') : t('common:save')}
|
||||
</button>
|
||||
{savedState === 'saved' && (
|
||||
<span className="text-[13px] text-green-600">{t('tools.saved')}</span>
|
||||
)}
|
||||
{savedState === 'error' && (
|
||||
<span className="text-[13px] text-red-600">{t('tools.saveFailedInline')}</span>
|
||||
)}
|
||||
{!canManage && (
|
||||
<span className="text-[13px] text-slate-400">{readonlyReason}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SpaceRail } from './SpaceRail';
|
||||
import { SpaceDetail } from './SpaceDetail';
|
||||
import { SpaceDetail, type SpaceChatFilter } from './SpaceDetail';
|
||||
import { WorkerStatusWidget } from '../dashboard/WorkerStatusWidget';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLocalStorageState } from '../../hooks/useLocalStorageState';
|
||||
@@ -13,6 +14,8 @@ interface SpacesPageProps {
|
||||
onSelectSpaceTask: (id: number) => void;
|
||||
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
onOpenTask: (id: number) => void;
|
||||
chatFilter: SpaceChatFilter;
|
||||
onChatFilterChange: (next: Partial<SpaceChatFilter>) => void;
|
||||
}
|
||||
|
||||
// レール幅の許容範囲。狭すぎると一覧が読めず、広すぎると詳細を圧迫するため上下限でクランプ。
|
||||
@@ -25,7 +28,8 @@ function clampRailWidth(px: number): number {
|
||||
return Math.max(RAIL_MIN_PX, Math.min(RAIL_MAX_PX, Math.round(px)));
|
||||
}
|
||||
|
||||
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask }: SpacesPageProps) {
|
||||
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpacesPageProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const isMobile = useIsMobile();
|
||||
const [railWidth, setRailWidth] = useLocalStorageState<number>('maestro.spaceRailWidth', RAIL_DEFAULT_PX);
|
||||
const [collapsed, setCollapsed] = useLocalStorageState<boolean>('maestro.spaceRailCollapsed', false);
|
||||
@@ -49,13 +53,13 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
data-testid="space-rail-collapse"
|
||||
onClick={() => setCollapsed(false)}
|
||||
aria-expanded={false}
|
||||
title="ワークスペース一覧を開く"
|
||||
title={t('page.expandRail')}
|
||||
className="hidden md:flex w-7 shrink-0 flex-col items-center justify-center gap-2 border-r border-hairline bg-surface text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider [writing-mode:vertical-rl]">ワークスペース</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider [writing-mode:vertical-rl]">{t('page.railLabel')}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
@@ -69,7 +73,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
data-testid="space-rail-collapse"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-expanded
|
||||
title="ワークスペース一覧を折りたたむ"
|
||||
title={t('page.collapseRail')}
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -105,7 +109,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M10 4l-4 4 4 4" />
|
||||
</svg>
|
||||
ワークスペース一覧
|
||||
{t('page.railList')}
|
||||
</button>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
@@ -116,6 +120,8 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
onSelectSpaceTask={onSelectSpaceTask}
|
||||
onCreateTask={onCreateTask}
|
||||
onOpenTask={onOpenTask}
|
||||
chatFilter={chatFilter}
|
||||
onChatFilterChange={onChatFilterChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,6 +133,7 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
// 親が clamp するので、ここでは絶対 X 座標からレール左端基準の幅を計算するだけ。
|
||||
// latest-ref パターンで drag 中に listener を貼り直さない。
|
||||
function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: number) => void }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const onResizeRef = useRef(onResize);
|
||||
onResizeRef.current = onResize;
|
||||
const draggingRef = useRef(false);
|
||||
@@ -172,7 +179,7 @@ function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: n
|
||||
ref={handleRef}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="ワークスペース一覧の幅を調整"
|
||||
aria-label={t('page.railResize')}
|
||||
data-testid="space-rail-resize"
|
||||
onPointerDown={handlePointerDown}
|
||||
className={`absolute top-0 right-0 z-10 h-full w-1.5 cursor-col-resize transition-colors hover:bg-slate-300/60 ${active ? 'bg-slate-400/60' : 'bg-transparent'}`}
|
||||
@@ -185,9 +192,11 @@ function RailResizeHandle({ width, onResize }: { width: number; onResize: (px: n
|
||||
|
||||
// レール下部に常駐する折りたたみ式のワーカー/GPU 状況パネル。Tasks ページと同じ
|
||||
// WorkerStatusWidget を使い、空きスロット(=投入余地)を一目で確認できるようにする。
|
||||
// 既定は折りたたみで、レール本体の高さを圧迫しない。
|
||||
// 既定は折りたたみで、レール本体の高さを圧迫しない。開閉状態は localStorage に保存し、
|
||||
// リロード・再マウントをまたいで維持する(毎回閉じ直す手間をなくす)。
|
||||
function WorkerStatusFooter() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation('spaces');
|
||||
const [open, setOpen] = useLocalStorageState('space.workerStatus.open', false);
|
||||
return (
|
||||
<div data-testid="space-worker-status" className="shrink-0 border-t border-hairline bg-surface">
|
||||
<button
|
||||
@@ -197,7 +206,7 @@ function WorkerStatusFooter() {
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-[11px] font-bold uppercase tracking-wider text-slate-500 transition-colors hover:text-slate-700"
|
||||
>
|
||||
<span>ワーカー / GPU</span>
|
||||
<span>{t('page.workerGpu')}</span>
|
||||
<svg
|
||||
className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 16 16"
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createSpaceGateway, createPublicAppGateway } from './app-file-gateway';
|
||||
|
||||
// read-only は writeFile/deleteFile の「不在」で表現する。認証版は両方を持ち、公開版は
|
||||
// 持たない。AppRunner はこの有無を見て read-only を判定する(プロパティ存在 = 書き込み可)。
|
||||
describe('app-file-gateway', () => {
|
||||
it('space gateway exposes write/delete (read-write)', () => {
|
||||
const g = createSpaceGateway('s1');
|
||||
expect(typeof g.writeFile).toBe('function');
|
||||
expect(typeof g.deleteFile).toBe('function');
|
||||
});
|
||||
|
||||
it('public app gateway has NO write/delete (read-only)', () => {
|
||||
const g = createPublicAppGateway('tok');
|
||||
expect(g.writeFile).toBeUndefined();
|
||||
expect(g.deleteFile).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rawUrl points at the matching backend endpoint for each gateway', () => {
|
||||
expect(createSpaceGateway('s1').rawUrl('apps/x/a.png')).toBe(
|
||||
'/api/local/spaces/s1/files/raw?path=apps%2Fx%2Fa.png',
|
||||
);
|
||||
expect(createPublicAppGateway('tok').rawUrl('apps/x/a.png')).toBe(
|
||||
'/api/app-share/tok/files/raw?path=apps%2Fx%2Fa.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// AppFileGateway — AppRunner のファイル I/O を抽象化する。
|
||||
//
|
||||
// AppRunner は同じサンドボックス iframe + postMessage ブリッジを、認証版(スペース
|
||||
// API・書き込み可)と公開版(app-share トークン API・read-only)の両方で使い回す。
|
||||
// その差分を吸収するのがこの gateway。
|
||||
//
|
||||
// READ-ONLY の表現:
|
||||
// read-only は writeFile/deleteFile プロパティの「不在」で表す(実行時 false を返す
|
||||
// メソッドではない)。AppRunner は `gateway.writeFile` が undefined かどうかで書き込み
|
||||
// 経路の有無を判定する。公開版は両メソッドを持たず、ブリッジが read-only エラーを返す。
|
||||
// サーバ側でも公開 API は GET のみなので、これは UI 側の二重防御に過ぎない。
|
||||
|
||||
import {
|
||||
fetchSpaceFileContent,
|
||||
fetchSpaceFiles,
|
||||
getSpaceFileRawUrl,
|
||||
writeSpaceFile,
|
||||
deleteSpaceFiles,
|
||||
fetchAppShareFileContent,
|
||||
fetchAppShareFiles,
|
||||
getAppShareRawUrl,
|
||||
type LocalFileEntry,
|
||||
} from '../../api';
|
||||
|
||||
export interface AppFileGateway {
|
||||
/** テキスト読み取り(workspace 相対パス)。 */
|
||||
fetchContent(path: string): Promise<string>;
|
||||
/** ディレクトリ一覧(workspace 相対パス)。 */
|
||||
listFiles(dir: string): Promise<{ entries: LocalFileEntry[] }>;
|
||||
/** raw アセット URL(rewriteRelativeAssets / img src 用)。 */
|
||||
rawUrl(path: string): string;
|
||||
/** 書き込み(read-only gateway では未定義)。 */
|
||||
writeFile?(path: string, content: string): Promise<{ ok: boolean }>;
|
||||
/** 削除(read-only gateway では未定義)。 */
|
||||
deleteFile?(path: string): Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
/** 認証版(スペース API)。write/delete あり。 */
|
||||
export function createSpaceGateway(spaceId: string): AppFileGateway {
|
||||
return {
|
||||
fetchContent: (path) => fetchSpaceFileContent(spaceId, path),
|
||||
listFiles: (dir) => fetchSpaceFiles(spaceId, dir),
|
||||
rawUrl: (path) => getSpaceFileRawUrl(spaceId, path),
|
||||
async writeFile(path, content) {
|
||||
await writeSpaceFile(spaceId, path, { content });
|
||||
return { ok: true };
|
||||
},
|
||||
async deleteFile(path) {
|
||||
await deleteSpaceFiles(spaceId, [path]);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 公開版(app-share トークン API)。read-only=write/delete は未定義。 */
|
||||
export function createPublicAppGateway(token: string): AppFileGateway {
|
||||
return {
|
||||
fetchContent: (path) => fetchAppShareFileContent(token, path),
|
||||
listFiles: (dir) => fetchAppShareFiles(token, dir),
|
||||
rawUrl: (path) => getAppShareRawUrl(token, path),
|
||||
// writeFile / deleteFile はあえて未定義(read-only)。
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildAppShareDisplayUrl } from './appShareUrl';
|
||||
|
||||
// shareUrl は相対パス(/ui/app/:token)。表示時に origin を前置するだけの純関数。
|
||||
describe('buildAppShareDisplayUrl', () => {
|
||||
it('prefixes the origin to a relative shareUrl', () => {
|
||||
expect(buildAppShareDisplayUrl('https://app.example.com', '/ui/app/abc')).toBe(
|
||||
'https://app.example.com/ui/app/abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not double a trailing slash on the origin', () => {
|
||||
expect(buildAppShareDisplayUrl('https://app.example.com/', '/ui/app/abc')).toBe(
|
||||
'https://app.example.com/ui/app/abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes absolute shareUrls through unchanged', () => {
|
||||
expect(buildAppShareDisplayUrl('https://app.example.com', 'https://other/x')).toBe(
|
||||
'https://other/x',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// 公開アプリ共有リンクの表示 URL 組み立て(純関数)。
|
||||
//
|
||||
// サーバの share API は shareUrl を相対パス(/ui/app/:token)で返す。表示・コピーの
|
||||
// ためにブラウザの origin を前置する。既に絶対 URL(scheme 付き)ならそのまま返す。
|
||||
export function buildAppShareDisplayUrl(origin: string, shareUrl: string): string {
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(shareUrl)) return shareUrl; // already absolute
|
||||
return `${origin.replace(/\/+$/, '')}${shareUrl.startsWith('/') ? '' : '/'}${shareUrl}`;
|
||||
}
|
||||
@@ -449,6 +449,7 @@ function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: s
|
||||
return (
|
||||
<div className="border border-hairline rounded-lg p-4">
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-3">{t('byUser.title')}</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-[11px] text-slate-400 text-left">
|
||||
@@ -475,6 +476,7 @@ function ByUserTable({ rows, t }: { rows: Array<{ userId: string; displayName: s
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -407,9 +407,9 @@ function ConnectionRow(props: ConnectionRowProps) {
|
||||
<span className="text-sm font-semibold text-slate-900 truncate">{c.label}</span>
|
||||
<ScopeBadge owner={c.ownerId} />
|
||||
<HostKeyBadge verified={verified} pending={pending} />
|
||||
{disabled && <Badge color="red">{c.disabledByAdmin ? 'admin-disabled' : 'disabled'}</Badge>}
|
||||
{c.allowRemoteUnrestricted && <Badge color="amber">remote: unrestricted</Badge>}
|
||||
{c.allowPrivateAddresses && <Badge color="amber">private addrs</Badge>}
|
||||
{disabled && <Badge color="red">{c.disabledByAdmin ? t('ssh.row.adminDisabled') : t('ssh.row.disabled')}</Badge>}
|
||||
{c.allowRemoteUnrestricted && <Badge color="amber">{t('ssh.row.remoteUnrestricted')}</Badge>}
|
||||
{c.allowPrivateAddresses && <Badge color="amber">{t('ssh.row.privateAddrs')}</Badge>}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-600 font-mono mt-1 truncate">
|
||||
{c.username}@{c.host}:{c.port}
|
||||
@@ -517,9 +517,10 @@ function ScopeBadge({ owner }: { owner: string | null }) {
|
||||
}
|
||||
|
||||
function HostKeyBadge({ verified, pending }: { verified: boolean; pending: boolean }) {
|
||||
if (pending) return <Badge color="amber">host-key pending</Badge>;
|
||||
if (verified) return <Badge color="emerald">host-key verified</Badge>;
|
||||
return <Badge color="slate">host-key untested</Badge>;
|
||||
const { t } = useTranslation('userfolder');
|
||||
if (pending) return <Badge color="amber">{t('ssh.hostKey.pending')}</Badge>;
|
||||
if (verified) return <Badge color="emerald">{t('ssh.hostKey.verified')}</Badge>;
|
||||
return <Badge color="slate">{t('ssh.hostKey.untested')}</Badge>;
|
||||
}
|
||||
|
||||
function Badge({ color, children }: { color: 'slate' | 'blue' | 'emerald' | 'amber' | 'red'; children: React.ReactNode }) {
|
||||
|
||||
Reference in New Issue
Block a user