This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for PythonPackagesPanel (per-space Python package UI).
|
||||
*
|
||||
* 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 } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
const { fetchMock, addMock, removeMock, fetchMembersMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
addMock: vi.fn(),
|
||||
removeMock: vi.fn(),
|
||||
fetchMembersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
fetchSpacePythonPackages: fetchMock,
|
||||
addSpacePythonPackage: addMock,
|
||||
removeSpacePythonPackage: removeMock,
|
||||
fetchSpaceMembers: fetchMembersMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../App', () => ({
|
||||
useAuthState: () => ({ mode: 'disabled' as const }),
|
||||
}));
|
||||
|
||||
import { PythonPackagesPanel } from './PythonPackagesPanel';
|
||||
|
||||
const ENABLED = {
|
||||
enabled: true,
|
||||
indexUrl: 'https://pypi.org/simple',
|
||||
maxPackagesPerSpace: 30,
|
||||
preflight: { ok: true },
|
||||
packages: [{ name: 'requests', spec: 'requests==2.32.3', addedAt: '2026-07-02T00:00:00Z' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
void i18n.changeLanguage('ja');
|
||||
fetchMembersMock.mockResolvedValue([]);
|
||||
fetchMock.mockResolvedValue(structuredClone(ENABLED));
|
||||
addMock.mockResolvedValue({ packages: [] });
|
||||
removeMock.mockResolvedValue({ packages: [] });
|
||||
});
|
||||
|
||||
describe('PythonPackagesPanel', () => {
|
||||
it('lists installed packages by their spec', async () => {
|
||||
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByText('requests==2.32.3')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('submits a typed spec via addSpacePythonPackage', async () => {
|
||||
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByTestId('python-add-input')).toBeInTheDocument());
|
||||
await userEvent.type(screen.getByTestId('python-add-input'), 'httpx==0.27.0');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await waitFor(() => expect(addMock).toHaveBeenCalledWith('s1', 'httpx==0.27.0'));
|
||||
});
|
||||
|
||||
it('removes a package via removeSpacePythonPackage', async () => {
|
||||
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByText('requests==2.32.3')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('削除'));
|
||||
await waitFor(() => expect(removeMock).toHaveBeenCalledWith('s1', 'requests'));
|
||||
});
|
||||
|
||||
it('shows a disabled notice and blocks the input when the feature is off', async () => {
|
||||
fetchMock.mockResolvedValue({ ...structuredClone(ENABLED), enabled: false, preflight: { ok: false, reason: 'feature disabled' } });
|
||||
renderWithProviders(<PythonPackagesPanel spaceId="s1" />);
|
||||
await waitFor(() => expect(screen.getByTestId('python-add-input')).toBeDisabled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* PythonPackagesPanel.tsx — ワークスペースごとの Python パッケージ管理 UI
|
||||
*
|
||||
* admin/オーナーが wheel パッケージ名を直接入力して、そのワークスペース専用の
|
||||
* オーバーレイに追加する。追加したパッケージは、そのワークスペースのエージェント
|
||||
* だけが `import` できる(他ワークスペースには波及しない)。
|
||||
*
|
||||
* - GET/POST/DELETE /api/local/spaces/:id/python-packages(canManageSpace が編集)
|
||||
* - インストールはサーバー側で out-of-band に実行(ネットワークは分離 bwrap のみ)。
|
||||
* wheels のみ許可(sdist の任意コード実行を回避)。
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchSpacePythonPackages,
|
||||
fetchSpaceMembers,
|
||||
addSpacePythonPackage,
|
||||
removeSpacePythonPackage,
|
||||
} from '../../api';
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
export function PythonPackagesPanel({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const auth = useAuthState();
|
||||
const qc = useQueryClient();
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
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-python-packages', spaceId],
|
||||
queryFn: () => fetchSpacePythonPackages(spaceId),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['space-python-packages', spaceId] });
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: (spec: string) => addSpacePythonPackage(spaceId, spec),
|
||||
onSuccess: () => {
|
||||
setInput('');
|
||||
showToast?.(t('python.added'), 'success');
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => showToast?.(t('python.addFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: (name: string) => removeSpacePythonPackage(spaceId, name),
|
||||
onSuccess: () => { showToast?.(t('python.removed'), 'success'); void invalidate(); },
|
||||
onError: (e) => showToast?.(t('python.removeFailed', { msg: errMsg(e) }), 'error'),
|
||||
});
|
||||
|
||||
const busy = addMut.isPending || removeMut.isPending;
|
||||
|
||||
const submit = () => {
|
||||
const spec = input.trim();
|
||||
if (!spec || busy) return;
|
||||
addMut.mutate(spec);
|
||||
};
|
||||
|
||||
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('python.fetchError', { msg: errMsg(error) })}</div>
|
||||
</div></div>
|
||||
);
|
||||
}
|
||||
|
||||
const disabledFeature = !data.enabled;
|
||||
const preflightBad = data.enabled && !data.preflight.ok;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto" data-testid="space-python-packages">
|
||||
<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('python.heading')}</h2>
|
||||
<p className="text-[13px] text-slate-500 leading-relaxed">{t('python.intro')}</p>
|
||||
</div>
|
||||
|
||||
{disabledFeature && (
|
||||
<div className="rounded-md border border-hairline bg-surface/40 px-4 py-3 text-[13px] text-slate-500">
|
||||
{t('python.disabled')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preflightBad && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50/60 dark:bg-amber-900/10 px-4 py-3 text-[13px] text-amber-800 dark:text-amber-300">
|
||||
{data.preflight.reason ?? t('python.preflightBad')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 追加フォーム */}
|
||||
<section>
|
||||
<label className="block text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
|
||||
{t('python.addLabel')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') submit(); }}
|
||||
disabled={!canManage || disabledFeature || busy}
|
||||
placeholder="requests==2.32.3"
|
||||
data-testid="python-add-input"
|
||||
className="h-9 flex-1 rounded-md border border-hairline px-3 text-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!canManage || disabledFeature || busy || !input.trim()}
|
||||
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"
|
||||
>
|
||||
{addMut.isPending ? t('python.installing') : t('python.add')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed mt-1.5">{t('python.addHint')}</p>
|
||||
</section>
|
||||
|
||||
{/* 一覧 */}
|
||||
<section>
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
|
||||
{t('python.installedHeading')}
|
||||
</h3>
|
||||
{data.packages.length === 0 ? (
|
||||
<p className="text-[13px] text-slate-400">{t('python.none')}</p>
|
||||
) : (
|
||||
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden">
|
||||
{data.packages.map(pkg => (
|
||||
<div key={pkg.name} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<code className="min-w-0 flex-1 truncate text-[13px] text-slate-900">{pkg.spec}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMut.mutate(pkg.name)}
|
||||
disabled={!canManage || busy}
|
||||
className="text-xs text-red-600 hover:text-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('python.remove')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!canManage && (
|
||||
<p className="text-[13px] text-slate-400">{t('python.readonly')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
fmtTimeBadge,
|
||||
layoutWeekBars,
|
||||
} from '../../lib/calendar';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isOfficePreviewable, officePreviewKind } from '../../lib/utils';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { FilePreview } from '../files/FilePreview';
|
||||
import type { OfficePreviewDescriptor } from '../files/FilePreview';
|
||||
@@ -313,8 +313,8 @@ function DayPanel({
|
||||
|
||||
const handlePreview = useCallback(async (filePath: string, name: string) => {
|
||||
try {
|
||||
if (isSpreadsheetPreviewable(name) || isPresentationPreviewable(name)) {
|
||||
const kind = isSpreadsheetPreviewable(name) ? 'spreadsheet' : 'presentation';
|
||||
if (isOfficePreviewable(name)) {
|
||||
const kind = officePreviewKind(name);
|
||||
setPreview({
|
||||
name,
|
||||
content: '',
|
||||
|
||||
@@ -21,7 +21,7 @@ import { filterAndSortTasks, groupTasksByStatus, statusCounts, totalTaskCount }
|
||||
import { filterTasksByScope, type TaskScope } from '../../lib/taskScope';
|
||||
import { workspaceDirRole } from '../../lib/workspaceDirs';
|
||||
import { FilterBar } from '../list/FilterBar';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isSpreadsheetPreviewable, isPresentationPreviewable } from '../../lib/utils';
|
||||
import { isImagePreviewable, isPdfPreviewable, isHtmlPreviewable, isOfficePreviewable, officePreviewKind } from '../../lib/utils';
|
||||
import type { OfficePreviewDescriptor } from '../files/FilePreview';
|
||||
import { CreateTaskDialog } from '../create/CreateTaskDialog';
|
||||
import { LocalTaskListItem } from '../list/TaskListItem';
|
||||
@@ -1015,8 +1015,8 @@ 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';
|
||||
if (isOfficePreviewable(name)) {
|
||||
const kind = officePreviewKind(name);
|
||||
setPreview({
|
||||
name,
|
||||
content: '',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
|
||||
import { SpaceMembersPanel } from './SpaceMembersPanel';
|
||||
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
|
||||
import { SpaceToolSettings } from './SpaceToolSettings';
|
||||
import { PythonPackagesPanel } from './PythonPackagesPanel';
|
||||
import { PieceEditor } from '../settings/PieceEditor';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { splitPieces } from '../../lib/splitPieces';
|
||||
@@ -30,7 +31,7 @@ import { useAuthState } from '../../App';
|
||||
|
||||
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
|
||||
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools';
|
||||
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python';
|
||||
|
||||
const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
|
||||
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' },
|
||||
@@ -41,6 +42,7 @@ const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
|
||||
{ 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: 'python', labelKey: 'settings.nav.python', testid: 'space-settings-nav-python' },
|
||||
{ id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' },
|
||||
];
|
||||
|
||||
@@ -85,6 +87,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
|
||||
{section === 'ssh' && <SshConnectionsPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'python' && <PythonPackagesPanel spaceId={spaceId} showToast={showToast} />}
|
||||
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,10 +122,8 @@ function SpacePiecesPanel({ spaceId, showToast }: { spaceId: string; showToast?:
|
||||
initial_movement: 'execute',
|
||||
movements: [{
|
||||
name: 'execute',
|
||||
edit: true,
|
||||
persona: 'worker',
|
||||
instruction: '',
|
||||
allowed_tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep'],
|
||||
default_next: 'COMPLETE',
|
||||
rules: [{ condition: '完了', next: 'COMPLETE' }],
|
||||
}],
|
||||
|
||||
Reference in New Issue
Block a user