This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { WorkerStatusRow } from '../../api';
|
||||
|
||||
// react-i18next: pass keys through (we only assert on the dynamic data).
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Pet hooks are irrelevant here — stub to the "no pet" path so the dot renders.
|
||||
vi.mock('../../hooks/useActivePet', () => ({ useActivePet: () => ({ data: null }) }));
|
||||
vi.mock('../../hooks/usePetFrameAnalysis', () => ({ usePetFrameAnalysis: () => 0 }));
|
||||
|
||||
const workersMock = vi.fn();
|
||||
vi.mock('../../hooks/useWorkerStatus', () => ({
|
||||
useWorkerStatus: () => workersMock(),
|
||||
}));
|
||||
|
||||
import { WorkerStatusWidget } from './WorkerStatusWidget';
|
||||
|
||||
function row(over: Partial<WorkerStatusRow>): WorkerStatusRow {
|
||||
return { id: 'w1', name: 'w1', roles: ['task'], state: 'running', proxy: false, ...over };
|
||||
}
|
||||
|
||||
describe('WorkerStatusWidget — occupants (admin)', () => {
|
||||
beforeEach(() => workersMock.mockReset());
|
||||
|
||||
it('renders occupant names, with a kind badge only for non-agent jobs', () => {
|
||||
workersMock.mockReturnValue({
|
||||
workers: [row({ occupants: [{ user: 'Alice', kind: 'agent' }, { user: 'Bob', kind: 'reflection' }] })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<WorkerStatusWidget />);
|
||||
const el = screen.getByTestId('worker-users-w1');
|
||||
expect(el.textContent).toContain('Alice');
|
||||
expect(el.textContent).toContain('Bob');
|
||||
// The reflection job surfaces a kind badge; the normal 'agent' job does not.
|
||||
const badge = screen.getByTestId('worker-kind-w1');
|
||||
expect(badge.textContent).toBe('reflection');
|
||||
expect(el.textContent).not.toContain('agent');
|
||||
});
|
||||
|
||||
it('renders no occupant line when occupants is absent (non-admin payload)', () => {
|
||||
workersMock.mockReturnValue({
|
||||
workers: [row({ occupants: undefined })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<WorkerStatusWidget />);
|
||||
expect(screen.queryByTestId('worker-users-w1')).toBeNull();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -10,6 +10,7 @@
|
||||
import '../../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../../test/render-helpers';
|
||||
import '../../../i18n';
|
||||
import type { LocalTask, LatestReflectionForTask } from '../../../api';
|
||||
@@ -22,10 +23,12 @@ vi.mock('../../../api', async (importOriginal) => {
|
||||
...actual,
|
||||
getLatestReflectionForTask: vi.fn().mockResolvedValue(null),
|
||||
fetchDelegateRuns: vi.fn().mockResolvedValue([]),
|
||||
putFeedback: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedReflection = vi.mocked(api.getLatestReflectionForTask);
|
||||
const mockedPutFeedback = vi.mocked(api.putFeedback);
|
||||
|
||||
function makeTask(overrides: Partial<LocalTask> = {}): LocalTask {
|
||||
return {
|
||||
@@ -93,4 +96,46 @@ describe('OverviewTab', () => {
|
||||
renderWithProviders(<OverviewTab task={makeTask()} />);
|
||||
expect(await screen.findByText(/Learned 3 things/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('feedback tags (i18n)', () => {
|
||||
it('localizes a previously-saved tag for display (EN), hiding the stored JA value', async () => {
|
||||
mockedReflection.mockResolvedValue(null);
|
||||
renderWithProviders(<OverviewTab task={makeTask({
|
||||
latestJob: { id: 'j', status: 'succeeded' },
|
||||
feedbackRating: 'good',
|
||||
feedbackTags: ['出力の精度が高い'],
|
||||
})} />);
|
||||
// The stored canonical JA string is shown localized; the raw JA is gone.
|
||||
expect(screen.getByText('Accurate output')).toBeInTheDocument();
|
||||
expect(screen.queryByText('出力の精度が高い')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw value for an unknown/legacy stored tag', async () => {
|
||||
mockedReflection.mockResolvedValue(null);
|
||||
renderWithProviders(<OverviewTab task={makeTask({
|
||||
latestJob: { id: 'j', status: 'succeeded' },
|
||||
feedbackRating: 'good',
|
||||
feedbackTags: ['レガシータグ'],
|
||||
})} />);
|
||||
expect(screen.getByText('レガシータグ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists the canonical JA value (not the localized label) when a tag is picked', async () => {
|
||||
mockedReflection.mockResolvedValue(null);
|
||||
mockedPutFeedback.mockResolvedValue(makeTask());
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<OverviewTab task={makeTask({ latestJob: { id: 'j', status: 'succeeded' } })} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Good/ }));
|
||||
// The tag picker shows the localized (EN) label...
|
||||
await user.click(screen.getByRole('button', { name: 'Accurate output' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Submit' }));
|
||||
|
||||
await waitFor(() => expect(mockedPutFeedback).toHaveBeenCalledTimes(1));
|
||||
// ...but the stored value stays the canonical JA string (backend/reflection contract).
|
||||
const [, body] = mockedPutFeedback.mock.calls[0];
|
||||
expect(body.tags).toEqual(['出力の精度が高い']);
|
||||
expect(body.rating).toBe('good');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,35 @@ import { ContextUsageGauge } from '../ContextUsageGauge';
|
||||
import { ReflectionBadge } from '../ReflectionBadge';
|
||||
import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly';
|
||||
|
||||
const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '指示をよく理解していた', '速度が適切だった'];
|
||||
const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた'];
|
||||
// Feedback tags. `value` is the canonical string that gets persisted (and fed
|
||||
// to the reflection LLM prompt) — it stays in Japanese so existing stored rows
|
||||
// and the backend are untouched. `key` is the i18n key used purely for display,
|
||||
// so the picker and the read-back chips localize with the UI language.
|
||||
const GOOD_TAGS = [
|
||||
{ value: '出力の精度が高い', key: 'feedback.tags.goodAccuracy' },
|
||||
{ value: 'フォーマットが適切', key: 'feedback.tags.goodFormat' },
|
||||
{ value: '指示をよく理解していた', key: 'feedback.tags.goodUnderstanding' },
|
||||
{ value: '速度が適切だった', key: 'feedback.tags.goodSpeed' },
|
||||
];
|
||||
const BAD_TAGS = [
|
||||
{ value: '出力の精度が低い', key: 'feedback.tags.badAccuracy' },
|
||||
{ value: 'フォーマットが不適切', key: 'feedback.tags.badFormat' },
|
||||
{ value: '指示と違う結果になった', key: 'feedback.tags.badMismatch' },
|
||||
{ value: '不要な作業をしていた', key: 'feedback.tags.badUnnecessary' },
|
||||
{ value: '途中で止まった / ASKが多すぎた', key: 'feedback.tags.badStalled' },
|
||||
];
|
||||
// value -> i18n key, for localizing previously-saved tags on read-back. Unknown
|
||||
// values (e.g. older/reworded tags) fall back to the stored string verbatim.
|
||||
const TAG_KEY = new Map<string, string>([...GOOD_TAGS, ...BAD_TAGS].map(t => [t.value, t.key]));
|
||||
|
||||
function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?: boolean }) {
|
||||
const { t } = useTranslation('detail');
|
||||
// Localize a stored tag value for display; fall back to the raw value if it's
|
||||
// not one of the known tags (keeps old/custom strings visible).
|
||||
const tagLabel = (value: string) => {
|
||||
const key = TAG_KEY.get(value);
|
||||
return key ? t(key) : value;
|
||||
};
|
||||
const qc = useQueryClient();
|
||||
const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed';
|
||||
const hasFeedback = !!task.feedbackRating;
|
||||
@@ -49,7 +73,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
|
||||
{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>
|
||||
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tagLabel(tag)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -92,7 +116,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
|
||||
{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>
|
||||
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tagLabel(tag)}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -130,15 +154,15 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
key={tag.value}
|
||||
onClick={() => toggleTag(tag.value)}
|
||||
className={`px-2 py-0.5 rounded-full text-2xs border transition-colors ${
|
||||
selectedTags.includes(tag)
|
||||
selectedTags.includes(tag.value)
|
||||
? 'bg-accent-soft border-accent text-accent'
|
||||
: 'border-slate-200 text-slate-500 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
{t(tag.key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useState } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useSpaces } from '../../hooks/useSpaces';
|
||||
|
||||
// ── API types ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface Candidate {
|
||||
type: 'mcp' | 'ssh';
|
||||
id: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
willSkip: boolean;
|
||||
skipReason?: string;
|
||||
}
|
||||
|
||||
interface CandidatesResponse {
|
||||
mcp: Candidate[];
|
||||
ssh: Candidate[];
|
||||
}
|
||||
|
||||
interface TransferResult {
|
||||
copied: { type: string; id: string; name: string }[];
|
||||
skipped: { type: string; name: string; reason: string }[];
|
||||
}
|
||||
|
||||
// ── API helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchCandidates(
|
||||
targetSpaceId: string,
|
||||
sourceId: string,
|
||||
): Promise<CandidatesResponse> {
|
||||
const res = await fetch(
|
||||
`/api/local/spaces/${encodeURIComponent(targetSpaceId)}/transfer/candidates?sourceId=${encodeURIComponent(sourceId)}`,
|
||||
{ credentials: 'include' },
|
||||
);
|
||||
if (res.status === 403) {
|
||||
const err = new Error('403');
|
||||
err.name = 'ForbiddenError';
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) throw new Error(`Failed to fetch candidates: ${res.status}`);
|
||||
return (await res.json()) as CandidatesResponse;
|
||||
}
|
||||
|
||||
async function postTransfer(
|
||||
targetSpaceId: string,
|
||||
body: { sourceId: string; mcpServerIds: string[]; sshConnectionIds: string[] },
|
||||
): Promise<TransferResult> {
|
||||
const res = await fetch(
|
||||
`/api/local/spaces/${encodeURIComponent(targetSpaceId)}/transfer`,
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try { const j = JSON.parse(text); if (j?.error) msg = j.error; } catch { /* ignore */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
return (await res.json()) as TransferResult;
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ImportFromWorkspaceDialogProps {
|
||||
targetSpaceId: string;
|
||||
kind: 'mcp' | 'ssh';
|
||||
open: boolean;
|
||||
onClose(): void;
|
||||
onImported(result: TransferResult): void;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function ImportFromWorkspaceDialog({
|
||||
targetSpaceId,
|
||||
kind,
|
||||
open,
|
||||
onClose,
|
||||
onImported,
|
||||
}: ImportFromWorkspaceDialogProps) {
|
||||
const { data: allSpaces, isLoading: spacesLoading } = useSpaces();
|
||||
const [sourceId, setSourceId] = useState<string>('');
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [transferError, setTransferError] = useState<string | null>(null);
|
||||
|
||||
// Exclude the target space from the source options.
|
||||
const sourceOptions = (allSpaces ?? []).filter(s => s.id !== targetSpaceId && s.status !== 'archived');
|
||||
|
||||
// Fetch candidates whenever a source is selected.
|
||||
const {
|
||||
data: candidates,
|
||||
isLoading: candidatesLoading,
|
||||
error: candidatesError,
|
||||
} = useQuery({
|
||||
queryKey: ['transfer-candidates', targetSpaceId, sourceId],
|
||||
queryFn: () => fetchCandidates(targetSpaceId, sourceId),
|
||||
enabled: !!sourceId,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const isForbidden =
|
||||
candidatesError instanceof Error && candidatesError.name === 'ForbiddenError';
|
||||
|
||||
// The list to display based on kind.
|
||||
const items: Candidate[] = candidates ? candidates[kind] : [];
|
||||
|
||||
const transferMut = useMutation({
|
||||
mutationFn: (ids: string[]) => {
|
||||
const body =
|
||||
kind === 'mcp'
|
||||
? { sourceId, mcpServerIds: ids, sshConnectionIds: [] }
|
||||
: { sourceId, mcpServerIds: [], sshConnectionIds: ids };
|
||||
return postTransfer(targetSpaceId, body);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
onImported(result);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => {
|
||||
setTransferError(err instanceof Error ? err.message : String(err));
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSourceChange = (id: string) => {
|
||||
setSourceId(id);
|
||||
setChecked(new Set());
|
||||
setTransferError(null);
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
setTransferError(null);
|
||||
transferMut.mutate(Array.from(checked));
|
||||
};
|
||||
|
||||
// Reset state when dialog opens/closes.
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setSourceId('');
|
||||
setChecked(new Set());
|
||||
setTransferError(null);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const checkedCount = checked.size;
|
||||
const canImport = checkedCount > 0 && !transferMut.isPending;
|
||||
|
||||
const kindLabel = kind === 'mcp' ? 'MCP サーバー' : 'SSH 接続';
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
|
||||
<Dialog.Content
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
|
||||
style={{ maxWidth: 'min(520px, 94vw)', maxHeight: '88dvh' }}
|
||||
>
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 mb-5">
|
||||
<div>
|
||||
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
|
||||
他のワークスペースから取り込む
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
|
||||
{kindLabel}を別のワークスペースからコピーします。
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
aria-label="閉じる"
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
{/* Source workspace selector */}
|
||||
<div className="flex flex-col gap-1 mb-4">
|
||||
<label className="text-xs font-semibold text-slate-600">
|
||||
コピー元ワークスペース
|
||||
</label>
|
||||
{spacesLoading ? (
|
||||
<div className="text-xs text-slate-400">読み込み中…</div>
|
||||
) : sourceOptions.length === 0 ? (
|
||||
<div className="text-xs text-slate-400">
|
||||
他にワークスペースがありません。
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={e => handleSourceChange(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">-- 選択してください --</option>
|
||||
{sourceOptions.map(s => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Candidates list */}
|
||||
{sourceId && (
|
||||
<div className="mb-4">
|
||||
{candidatesLoading && (
|
||||
<div className="text-xs text-slate-400 py-3">候補を読み込み中…</div>
|
||||
)}
|
||||
|
||||
{isForbidden && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
このワークスペースを管理する権限がありません。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{candidatesError && !isForbidden && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
候補の取得に失敗しました: {(candidatesError as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!candidatesLoading && !candidatesError && items.length === 0 && (
|
||||
<div className="text-xs text-slate-400 py-3 text-center">
|
||||
取り込める{kindLabel}がありません。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!candidatesLoading && !candidatesError && items.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-slate-600 mb-2">{kindLabel}</div>
|
||||
<ul className="divide-y divide-hairline border border-hairline rounded-md overflow-hidden">
|
||||
{items.map(item => (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`flex items-start gap-3 px-3 py-2.5 ${
|
||||
item.willSkip ? 'opacity-60 bg-slate-50' : 'bg-canvas'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`candidate-${item.id}`}
|
||||
checked={checked.has(item.id)}
|
||||
onChange={() => handleToggle(item.id)}
|
||||
disabled={item.willSkip}
|
||||
className="mt-0.5 h-4 w-4 flex-shrink-0 rounded border-hairline text-accent focus:ring-accent-ring disabled:cursor-not-allowed"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`candidate-${item.id}`}
|
||||
className={`flex-1 min-w-0 ${item.willSkip ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[13px] font-medium text-slate-800">
|
||||
{item.name}
|
||||
</span>
|
||||
{item.willSkip && (
|
||||
<span
|
||||
title={item.skipReason ?? 'すでに存在します'}
|
||||
className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 leading-none cursor-help"
|
||||
>
|
||||
スキップ(既存)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-2xs text-slate-500 mt-0.5 font-mono truncate">
|
||||
{item.detail}
|
||||
</div>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Select all / deselect */}
|
||||
{items.some(i => !i.willSkip) && (
|
||||
<div className="mt-1.5 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChecked(new Set(items.filter(i => !i.willSkip).map(i => i.id)))}
|
||||
className="text-2xs text-accent hover:underline"
|
||||
>
|
||||
すべて選択
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChecked(new Set())}
|
||||
className="text-2xs text-slate-500 hover:underline"
|
||||
>
|
||||
選択解除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transfer error */}
|
||||
{transferError && (
|
||||
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
取り込みに失敗しました: {transferError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-hairline px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={!canImport}
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{transferMut.isPending
|
||||
? '取り込み中…'
|
||||
: `取り込む${checkedCount > 0 ? ` (${checkedCount})` : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces';
|
||||
@@ -425,21 +425,39 @@ function SpaceChat({
|
||||
const qc = useQueryClient();
|
||||
const auth = useAuthState();
|
||||
const { data: allTasks } = useLocalTaskList();
|
||||
const { data: spaces } = useSpaces();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
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 側、という
|
||||
// 既存規約に合わせる)。フィルタ状態は 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);
|
||||
// 認証無効 (no-auth 単独利用) は admin 同等に扱う(App.tsx の isAdmin と同じ規約)。
|
||||
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
|
||||
// case スペースの id 集合。個人バケツ判定(null か case でない id = 誰かの個人
|
||||
// スペース)に使う。admin の listSpaces は全 case スペースを返すので集合は完全。
|
||||
const caseSpaceIds = useMemo(
|
||||
() => new Set((spaces ?? []).filter(s => s.kind === 'case').map(s => s.id)),
|
||||
[spaces],
|
||||
);
|
||||
|
||||
let spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace, caseSpaceIds);
|
||||
// 可視性ルール: 個人ワークスペースは本人専用。admin だけが他ユーザーの個人コンテンツを
|
||||
// 「他のメンバー」タブで監視できる。非 admin の個人スペースは自分の所有分に絞り、他人を
|
||||
// 一切出さない(リスト API が public/org 等を返しても個人 WS には混ぜない)。
|
||||
if (isPersonalSpace && !isAdmin && userId != null) {
|
||||
spaceTasks = spaceTasks.filter(t => t.ownerId === userId);
|
||||
}
|
||||
|
||||
// 自分/他メンバーの切替。共有ワークスペースのメンバー間、または個人スペースを admin が
|
||||
// 監視するときに「他の人のタスク」が存在する場合だけ出す。スコープ分割は Tasks ページと
|
||||
// 同じ filterTasksByScope を再利用する(owner_id null は others 側、という既存規約に合わ
|
||||
// せる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の onSelectSpace が
|
||||
// search/status/sort/scope を明示リセットする(remount 依存ではない)。
|
||||
const othersAllowed = !isPersonalSpace || isAdmin;
|
||||
const hasOthersTasks = othersAllowed && userId != null && spaceTasks.some(t => t.ownerId !== userId);
|
||||
const tasks = userId != null && hasOthersTasks
|
||||
? filterTasksByScope(spaceTasks, scope, userId)
|
||||
: spaceTasks;
|
||||
|
||||
@@ -95,7 +95,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
space={s}
|
||||
active={s.id === selectedId}
|
||||
onSelect={onSelect}
|
||||
runningCount={countRunningTasksForSpace(tasks ?? [], s)}
|
||||
runningCount={countRunningTasksForSpace(tasks ?? [], s, myUserId)}
|
||||
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -28,6 +28,7 @@ const SENSITIVE_NOTE_KEYS: Record<string, string> = {
|
||||
ssh: 'tools.sensitiveNote.ssh',
|
||||
browser: 'tools.sensitiveNote.browser',
|
||||
Bash: 'tools.sensitiveNote.Bash',
|
||||
SpawnSubTask: 'tools.sensitiveNote.SpawnSubTask',
|
||||
};
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthState } from '../../App';
|
||||
import { ImportFromWorkspaceDialog } from '../spaces/ImportFromWorkspaceDialog';
|
||||
|
||||
interface ServerPublic {
|
||||
id: string;
|
||||
@@ -296,6 +297,7 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [addingSection, setAddingSection] = useState<'global' | 'personal' | null>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
|
||||
const invalidateAll = () => {
|
||||
qc.invalidateQueries({ queryKey: ['mcp-servers-admin'] });
|
||||
@@ -423,11 +425,22 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
|
||||
return (
|
||||
<div className="h-full overflow-y-auto" data-testid={inSpace ? 'space-mcp-panel' : undefined}>
|
||||
<div className="max-w-3xl mx-auto px-6 py-8 space-y-8">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('mcp.title')}</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
{t('mcp.intro')}
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('mcp.title')}</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">
|
||||
{t('mcp.intro')}
|
||||
</p>
|
||||
</div>
|
||||
{inSpace && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-md text-xs font-semibold text-slate-600 border border-hairline hover:bg-surface transition-colors"
|
||||
>
|
||||
他のWSから取り込む
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-[13px] text-slate-400">{t('mcp.loading')}</div>}
|
||||
@@ -493,6 +506,21 @@ export function McpPanel({ spaceId, showToast }: { spaceId?: string; showToast?:
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inSpace && importDialogOpen && (
|
||||
<ImportFromWorkspaceDialog
|
||||
targetSpaceId={spaceId!}
|
||||
kind="mcp"
|
||||
open={importDialogOpen}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
onImported={(result) => {
|
||||
qc.invalidateQueries({ queryKey: ['mcp-user-servers', spaceId ?? null] });
|
||||
const n = result.copied.length;
|
||||
const m = result.skipped.length;
|
||||
showToast?.(`${n}件コピー、${m}件スキップ`, 'success');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SshConnection, TestResponse } from '../../lib/ssh-types';
|
||||
import { SshConnectionForm } from './SshConnectionForm';
|
||||
import { SshHostKeyDialog } from './SshHostKeyDialog';
|
||||
import { SshPublicKeyDialog } from './SshPublicKeyDialog';
|
||||
import { ImportFromWorkspaceDialog } from '../spaces/ImportFromWorkspaceDialog';
|
||||
|
||||
interface ConnectionsResponse {
|
||||
connections: SshConnection[];
|
||||
@@ -143,6 +144,7 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
|
||||
});
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ id: string; test: TestResponse; replaceMode: boolean } | null>(null);
|
||||
const [pubKeyDialog, setPubKeyDialog] = useState<{
|
||||
@@ -256,14 +258,25 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
|
||||
{t('ssh.intro')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreating(true); setEditingId(null); }}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
|
||||
disabled={creating}
|
||||
>
|
||||
{t('ssh.create')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{spaceId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportDialogOpen(true)}
|
||||
className="px-3 h-7 text-xs font-semibold text-slate-600 border border-hairline rounded-md hover:bg-surface transition-colors"
|
||||
>
|
||||
他のWSから取り込む
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setCreating(true); setEditingId(null); }}
|
||||
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep"
|
||||
disabled={creating}
|
||||
>
|
||||
{t('ssh.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <div className="text-xs text-slate-400">{t('common.loading')}</div>}
|
||||
@@ -358,6 +371,21 @@ export function SshConnectionsPanel({ spaceId, showToast }: SshConnectionsPanelP
|
||||
onClose={() => setPubKeyDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{spaceId && importDialogOpen && (
|
||||
<ImportFromWorkspaceDialog
|
||||
targetSpaceId={spaceId}
|
||||
kind="ssh"
|
||||
open={importDialogOpen}
|
||||
onClose={() => setImportDialogOpen(false)}
|
||||
onImported={(result) => {
|
||||
qc.invalidateQueries({ queryKey: ['ssh', 'connections', spaceId ?? null] });
|
||||
const n = result.copied.length;
|
||||
const m = result.skipped.length;
|
||||
showToast?.(`${n}件コピー、${m}件スキップ`, 'success');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user