sync: update from private repo (a5db2ab0)
CI / build-and-test (push) Successful in 8m58s

This commit is contained in:
oss-sync
2026-07-10 00:51:05 +00:00
parent 2044f0a2c4
commit a67c40d33c
45 changed files with 3599 additions and 15 deletions
+90
View File
@@ -267,6 +267,96 @@ export async function removeSpacePythonPackage(
return data as { packages: SpacePythonPackage[] };
}
// ─── Per-space webhook notifications (issue #797, PR1: Discord only) ─────
export type SpaceWebhookProvider = 'discord' | 'slack' | 'teams';
export type SpaceWebhookEvent = 'succeeded' | 'failed' | 'waiting_human';
export interface SpaceWebhook {
id: string;
provider: SpaceWebhookProvider;
label: string;
events: SpaceWebhookEvent[];
includeDetails: boolean;
enabled: boolean;
disabledReason: string | null;
lastSuccessAt: string | null;
lastFailureAt: string | null;
failureCount: number;
createdAt: string;
updatedAt: string;
// NOTE: no `url` / `urlEnc` field — the server never returns the webhook
// URL once saved (write-only secret).
}
export async function fetchSpaceWebhooks(spaceId: string): Promise<SpaceWebhook[]> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch webhooks');
return (data?.webhooks ?? []) as SpaceWebhook[];
}
export async function createSpaceWebhook(
spaceId: string,
input: { provider: SpaceWebhookProvider; label: string; url: string; events: SpaceWebhookEvent[]; includeDetails?: boolean },
): Promise<SpaceWebhook> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to create webhook');
return data as SpaceWebhook;
}
export async function updateSpaceWebhook(
spaceId: string,
webhookId: string,
patch: { label?: string; url?: string; events?: SpaceWebhookEvent[]; includeDetails?: boolean },
): Promise<SpaceWebhook> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to update webhook');
return data as SpaceWebhook;
}
export async function setSpaceWebhookEnabled(
spaceId: string,
webhookId: string,
enabled: boolean,
): Promise<SpaceWebhook> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.error ?? 'Failed to update webhook');
return data as SpaceWebhook;
}
export async function deleteSpaceWebhook(spaceId: string, webhookId: string): Promise<void> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, { method: 'DELETE' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d?.error ?? 'Failed to delete webhook');
}
}
export async function testSendSpaceWebhook(
spaceId: string,
webhookId: string,
): Promise<{ ok: boolean; error?: string }> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}/test`, { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: data?.error ?? 'Failed to send test notification' };
return data as { ok: boolean; error?: string };
}
export async function createSpace(input: {
title: string;
description?: string;
@@ -46,4 +46,27 @@ describe('SettingsSidebar search', () => {
// Safety is under an adminOnly group → not searchable for a non-admin.
expect(screen.queryByTestId('settings-search-result-safety')).not.toBeInTheDocument();
});
it('hides the A2A Delegations section when a2a is disabled (default fail-closed)', () => {
const onSelect = vi.fn();
// Omitting a2aEnabled must default to hidden — the delegations API route
// only exists when a2a.enabled, so an always-shown section errors out.
renderWithProviders(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
expect(screen.queryByTestId('settings-nav-a2a-delegations')).not.toBeInTheDocument();
// A sibling per-user section stays visible.
expect(screen.getByTestId('settings-nav-pets')).toBeInTheDocument();
});
it('shows the A2A Delegations section when a2a is enabled', () => {
const onSelect = vi.fn();
renderWithProviders(<SettingsSidebar isAdmin a2aEnabled onSelectSection={onSelect} />);
expect(screen.getByTestId('settings-nav-a2a-delegations')).toBeInTheDocument();
});
it('does not surface A2A Delegations in search when a2a is disabled', () => {
const onSelect = vi.fn();
renderWithProviders(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'delegation' } });
expect(screen.queryByTestId('settings-search-result-a2a-delegations')).not.toBeInTheDocument();
});
});
+18 -3
View File
@@ -6,6 +6,12 @@ interface SettingsSidebarProps {
activeSection?: string;
onSelectSection: (section: string) => void;
isAdmin: boolean;
/**
* Whether A2A is enabled server-side. The A2A Delegations section is per-user
* but its API route (`/api/local/a2a/delegations`) only mounts when a2a.enabled,
* so the section is hidden unless this is true (defaults false = fail-closed).
*/
a2aEnabled?: boolean;
}
/**
@@ -135,14 +141,23 @@ export const USER_SECTIONS: string[] = CONFIG_GROUPS
.filter(g => !('adminOnly' in g) || !g.adminOnly)
.flatMap(g => g.sections.map(s => s.id));
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) {
/** Sections gated on a runtime feature flag rather than admin role. */
function isSectionAvailable(sectionId: string, a2aEnabled: boolean): boolean {
if (sectionId === 'a2a-delegations') return a2aEnabled;
return true;
}
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin, a2aEnabled = false }: SettingsSidebarProps) {
const { t } = useTranslation('settings');
const [query, setQuery] = useState('');
const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly);
const visibleGroups = CONFIG_GROUPS
.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly)
.map(g => ({ ...g, sections: g.sections.filter(s => isSectionAvailable(s.id, a2aEnabled)) }))
.filter(g => g.sections.length > 0);
// Only sections the current user can actually open are searchable.
const visibleIds = useMemo(
() => new Set(visibleGroups.flatMap(g => g.sections.map(s => s.id))),
() => new Set<string>(visibleGroups.flatMap(g => g.sections.map(s => s.id))),
[visibleGroups],
);
const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]);
+4 -1
View File
@@ -22,6 +22,7 @@ import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
import { SpaceMembersPanel } from './SpaceMembersPanel';
import { SpaceBrowserPanel } from './SpaceBrowserPanel';
import { SpaceToolSettings } from './SpaceToolSettings';
import { SpaceWebhookSettings } from './SpaceWebhookSettings';
import { PythonPackagesPanel } from './PythonPackagesPanel';
import { PieceEditor } from '../settings/PieceEditor';
import { usePieceList } from '../../hooks/usePieces';
@@ -31,7 +32,7 @@ import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python';
type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python' | 'webhooks';
const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' },
@@ -43,6 +44,7 @@ const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
{ 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: 'webhooks', labelKey: 'settings.nav.webhooks', testid: 'space-settings-nav-webhooks' },
{ id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' },
];
@@ -88,6 +90,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa
{section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
{section === 'python' && <PythonPackagesPanel spaceId={spaceId} showToast={showToast} />}
{section === 'webhooks' && <SpaceWebhookSettings spaceId={spaceId} showToast={showToast} />}
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
</div>
</div>
@@ -0,0 +1,167 @@
// @vitest-environment jsdom
/**
* Component tests for SpaceWebhookSettings (per-space webhook notification 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, createMock, deleteMock, setEnabledMock, testSendMock, fetchMembersMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
createMock: vi.fn(),
deleteMock: vi.fn(),
setEnabledMock: vi.fn(),
testSendMock: vi.fn(),
fetchMembersMock: vi.fn(),
}));
vi.mock('../../api', () => ({
fetchSpaceWebhooks: fetchMock,
createSpaceWebhook: createMock,
updateSpaceWebhook: vi.fn(),
setSpaceWebhookEnabled: setEnabledMock,
deleteSpaceWebhook: deleteMock,
testSendSpaceWebhook: testSendMock,
fetchSpaceMembers: fetchMembersMock,
}));
vi.mock('../../App', () => ({
useAuthState: () => ({ mode: 'disabled' as const }),
}));
import { SpaceWebhookSettings } from './SpaceWebhookSettings';
const ONE_WEBHOOK = [
{
id: 'wh-1',
provider: 'discord' as const,
label: 'Dev channel',
events: ['succeeded', 'failed'] as const,
includeDetails: true,
enabled: true,
disabledReason: null,
lastSuccessAt: '2026-07-01T00:00:00Z',
lastFailureAt: null,
failureCount: 0,
createdAt: '2026-06-01T00:00:00Z',
updatedAt: '2026-06-01T00:00:00Z',
},
];
const AUTO_DISABLED_WEBHOOK = [
{
...ONE_WEBHOOK[0],
id: 'wh-2',
enabled: false,
disabledReason: '10 consecutive delivery failures',
failureCount: 10,
},
];
beforeEach(() => {
vi.clearAllMocks();
void i18n.changeLanguage('ja');
fetchMembersMock.mockResolvedValue([]);
fetchMock.mockResolvedValue([]);
createMock.mockResolvedValue(ONE_WEBHOOK[0]);
deleteMock.mockResolvedValue(undefined);
setEnabledMock.mockResolvedValue({ ...ONE_WEBHOOK[0], enabled: true });
testSendMock.mockResolvedValue({ ok: true });
});
describe('SpaceWebhookSettings', () => {
it('renders an empty state when there are no webhooks', async () => {
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('space-webhook-settings')).toBeInTheDocument());
expect(screen.getByText('まだ何も登録されていません。')).toBeInTheDocument();
});
it('adds a webhook via the form', async () => {
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('webhook-label-input')).toBeInTheDocument());
await userEvent.type(screen.getByTestId('webhook-label-input'), 'Dev channel');
await userEvent.type(screen.getByTestId('webhook-url-input'), 'https://discord.com/api/webhooks/1/2');
await userEvent.click(screen.getByText('追加'));
await waitFor(() => expect(createMock).toHaveBeenCalledWith('s1', {
provider: 'discord',
label: 'Dev channel',
url: 'https://discord.com/api/webhooks/1/2',
events: ['succeeded', 'failed', 'waiting_human'],
includeDetails: true,
}));
});
it('lists an existing webhook and deletes it', async () => {
fetchMock.mockResolvedValue(ONE_WEBHOOK);
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByText('Dev channel')).toBeInTheDocument());
await userEvent.click(screen.getByTestId('webhook-delete-wh-1'));
await waitFor(() => expect(deleteMock).toHaveBeenCalledWith('s1', 'wh-1'));
});
it('sends a test notification via the per-webhook test button', async () => {
fetchMock.mockResolvedValue(ONE_WEBHOOK);
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('webhook-test-wh-1')).toBeInTheDocument());
await userEvent.click(screen.getByTestId('webhook-test-wh-1'));
await waitFor(() => expect(testSendMock).toHaveBeenCalledWith('s1', 'wh-1'));
await waitFor(() => expect(screen.getByText('テスト通知を送信しました。')).toBeInTheDocument());
});
it('shows the auto-disabled badge and a re-enable action for a disabled webhook', async () => {
fetchMock.mockResolvedValue(AUTO_DISABLED_WEBHOOK);
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('webhook-disabled-badge-wh-2')).toBeInTheDocument());
expect(screen.getByText('自動停止中')).toBeInTheDocument();
expect(screen.getByText('10 consecutive delivery failures')).toBeInTheDocument();
const reenableBtn = screen.getByTestId('webhook-reenable-wh-2');
await userEvent.click(reenableBtn);
await waitFor(() => expect(setEnabledMock).toHaveBeenCalledWith('s1', 'wh-2', true));
});
it('disables the add button until label, url, and at least one event are present', async () => {
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByText('追加')).toBeDisabled());
});
it('offers Discord, Slack, and Microsoft Teams in the provider select', async () => {
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('webhook-provider-select')).toBeInTheDocument());
const select = screen.getByTestId('webhook-provider-select') as HTMLSelectElement;
const optionLabels = Array.from(select.options).map(o => o.value);
expect(optionLabels).toEqual(['discord', 'slack', 'teams']);
});
it('adds a Teams webhook via the form', async () => {
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('webhook-provider-select')).toBeInTheDocument());
await userEvent.selectOptions(screen.getByTestId('webhook-provider-select'), 'teams');
await userEvent.type(screen.getByTestId('webhook-label-input'), 'Ops channel');
await userEvent.type(
screen.getByTestId('webhook-url-input'),
'https://example.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz',
);
await userEvent.click(screen.getByText('追加'));
await waitFor(() => expect(createMock).toHaveBeenCalledWith('s1', {
provider: 'teams',
label: 'Ops channel',
url: 'https://example.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz',
events: ['succeeded', 'failed', 'waiting_human'],
includeDetails: true,
}));
});
});
@@ -0,0 +1,370 @@
/**
* SpaceWebhookSettings.tsx — ワークスペースごとの Webhook 通知設定 UI
*
* PR1 で Discord、PR2 で Slack、PR3 で Microsoft Teams を実装。issue #797。
*
* - タスク完了・失敗・回答待ちを Discord / Slack / Microsoft Teams チャンネルへ
* 通知する Webhook をワークスペース単位で追加・削除・テスト送信できる。
* - Webhook URL は保存後に再表示されない(write-only)。変更は再入力で上書き。
* - 3 回連続失敗で警告、10 回連続失敗で自動停止(enabled=0 + disabledReason)。
* 自動停止後は編集権限メンバーが再有効化できる。
* - GET/POST/PUT/PATCH/DELETE /api/local/spaces/:id/webhookscanManageSpace が編集)。
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
fetchSpaceWebhooks,
fetchSpaceMembers,
createSpaceWebhook,
updateSpaceWebhook,
setSpaceWebhookEnabled,
deleteSpaceWebhook,
testSendSpaceWebhook,
type SpaceWebhook,
type SpaceWebhookEvent,
type SpaceWebhookProvider,
} from '../../api';
import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void;
const ALL_EVENTS: SpaceWebhookEvent[] = ['succeeded', 'failed', 'waiting_human'];
const WARNING_FAILURE_THRESHOLD = 3;
// All three known providers have a working adapter as of PR3 (space-webhooks-api.ts IMPLEMENTED_PROVIDERS).
const IMPLEMENTED_PROVIDERS: SpaceWebhookProvider[] = ['discord', 'slack', 'teams'];
const URL_PLACEHOLDER: Record<SpaceWebhookProvider, string> = {
discord: 'https://discord.com/api/webhooks/...',
slack: 'https://hooks.slack.com/services/...',
teams: 'https://xxx.webhook.office.com/webhookb2/...',
};
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
export function SpaceWebhookSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) {
const { t } = useTranslation('spaces');
const auth = useAuthState();
const qc = useQueryClient();
const [provider, setProvider] = useState<SpaceWebhookProvider>('discord');
const [label, setLabel] = useState('');
const [url, setUrl] = useState('');
const [events, setEvents] = useState<SpaceWebhookEvent[]>(['succeeded', 'failed', 'waiting_human']);
const [includeDetails, setIncludeDetails] = useState(true);
const [editingUrlFor, setEditingUrlFor] = useState<string | null>(null);
const [editUrlValue, setEditUrlValue] = useState('');
const [testResult, setTestResult] = useState<Record<string, 'ok' | 'error'>>({});
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-webhooks', spaceId],
queryFn: () => fetchSpaceWebhooks(spaceId),
staleTime: 15_000,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['space-webhooks', spaceId] });
const createMut = useMutation({
mutationFn: () => createSpaceWebhook(spaceId, { provider, label: label.trim(), url: url.trim(), events, includeDetails }),
onSuccess: () => {
setProvider('discord');
setLabel('');
setUrl('');
setEvents(['succeeded', 'failed', 'waiting_human']);
setIncludeDetails(true);
showToast?.(t('webhooks.added'), 'success');
void invalidate();
},
onError: (e) => showToast?.(t('webhooks.addFailed', { msg: errMsg(e) }), 'error'),
});
const deleteMut = useMutation({
mutationFn: (id: string) => deleteSpaceWebhook(spaceId, id),
onSuccess: () => { showToast?.(t('webhooks.removed'), 'success'); void invalidate(); },
onError: (e) => showToast?.(t('webhooks.removeFailed', { msg: errMsg(e) }), 'error'),
});
const enableMut = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => setSpaceWebhookEnabled(spaceId, id, enabled),
onSuccess: () => { void invalidate(); },
onError: (e) => showToast?.(t('webhooks.updateFailed', { msg: errMsg(e) }), 'error'),
});
const updateUrlMut = useMutation({
mutationFn: ({ id, newUrl }: { id: string; newUrl: string }) => updateSpaceWebhook(spaceId, id, { url: newUrl }),
onSuccess: () => {
setEditingUrlFor(null);
setEditUrlValue('');
showToast?.(t('webhooks.urlUpdated'), 'success');
void invalidate();
},
onError: (e) => showToast?.(t('webhooks.updateFailed', { msg: errMsg(e) }), 'error'),
});
const testMut = useMutation({
mutationFn: (id: string) => testSendSpaceWebhook(spaceId, id),
onSuccess: (result, id) => {
setTestResult(prev => ({ ...prev, [id]: result.ok ? 'ok' : 'error' }));
showToast?.(result.ok ? t('webhooks.testSent') : t('webhooks.testFailed', { msg: result.error ?? '' }), result.ok ? 'success' : 'error');
},
onError: (e, id) => {
setTestResult(prev => ({ ...prev, [id]: 'error' }));
showToast?.(t('webhooks.testFailed', { msg: errMsg(e) }), 'error');
},
});
const busy = createMut.isPending || deleteMut.isPending || enableMut.isPending || updateUrlMut.isPending;
const toggleEvent = (ev: SpaceWebhookEvent) => {
setEvents(prev => (prev.includes(ev) ? prev.filter(e => e !== ev) : [...prev, ev]));
};
const submit = () => {
if (!label.trim() || !url.trim() || events.length === 0 || createMut.isPending) return;
createMut.mutate();
};
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('webhooks.fetchError', { msg: errMsg(error) })}</div>
</div></div>
);
}
return (
<div className="h-full overflow-y-auto" data-testid="space-webhook-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('webhooks.heading')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">{t('webhooks.intro')}</p>
</div>
{/* 追加フォーム */}
<section className="space-y-2 rounded-md border border-hairline bg-surface/40 p-4">
<label className="block text-[11px] font-semibold uppercase tracking-wide text-slate-500">
{t('webhooks.addLabel')}
</label>
<select
value={provider}
onChange={e => setProvider(e.target.value as SpaceWebhookProvider)}
disabled={!canManage || busy}
data-testid="webhook-provider-select"
className="h-9 w-full 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"
>
{IMPLEMENTED_PROVIDERS.map(p => (
<option key={p} value={p}>{t(`webhooks.provider.${p}`)}</option>
))}
</select>
<input
value={label}
onChange={e => setLabel(e.target.value)}
disabled={!canManage || busy}
placeholder={t('webhooks.labelPlaceholder')}
data-testid="webhook-label-input"
className="h-9 w-full 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"
/>
<input
value={url}
onChange={e => setUrl(e.target.value)}
disabled={!canManage || busy}
placeholder={URL_PLACEHOLDER[provider]}
data-testid="webhook-url-input"
className="h-9 w-full 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"
/>
<div className="flex flex-wrap items-center gap-3 text-[13px] text-slate-600">
{ALL_EVENTS.map(ev => (
<label key={ev} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={events.includes(ev)}
onChange={() => toggleEvent(ev)}
disabled={!canManage || busy}
/>
{t(`webhooks.event.${ev}`)}
</label>
))}
</div>
<label className="flex items-center gap-1.5 text-[13px] text-slate-600">
<input
type="checkbox"
checked={includeDetails}
onChange={e => setIncludeDetails(e.target.checked)}
disabled={!canManage || busy}
/>
{t('webhooks.includeDetails')}
</label>
<button
type="button"
onClick={submit}
disabled={!canManage || busy || !label.trim() || !url.trim() || events.length === 0}
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"
>
{createMut.isPending ? t('webhooks.adding') : t('webhooks.add')}
</button>
<p className="text-2xs text-slate-500 leading-relaxed">{t(`webhooks.urlHint.${provider}`)}</p>
</section>
{/* 一覧 */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
{t('webhooks.listHeading')}
</h3>
{data.length === 0 ? (
<p className="text-[13px] text-slate-400">{t('webhooks.none')}</p>
) : (
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden">
{data.map((wh: SpaceWebhook) => {
const isWarning = wh.enabled && wh.failureCount >= WARNING_FAILURE_THRESHOLD;
const isEditingUrl = editingUrlFor === wh.id;
return (
<div key={wh.id} className="px-3 py-3 space-y-2" data-testid={`webhook-row-${wh.id}`}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-slate-900">{wh.label}</span>
<span className="text-2xs text-slate-400 uppercase">{wh.provider}</span>
{!wh.enabled && (
<span
data-testid={`webhook-disabled-badge-${wh.id}`}
className="text-2xs rounded px-1.5 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{t('webhooks.autoDisabledBadge')}
</span>
)}
{wh.enabled && isWarning && (
<span className="text-2xs rounded px-1.5 py-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{t('webhooks.warningBadge', { count: wh.failureCount })}
</span>
)}
</div>
<div className="text-2xs text-slate-500">
{wh.events.map(ev => t(`webhooks.event.${ev}`)).join(' / ')}
{wh.includeDetails ? '' : ` · ${t('webhooks.detailsOff')}`}
</div>
<div className="text-2xs text-slate-500 space-y-0.5">
<div>{t('webhooks.lastSuccess', { at: wh.lastSuccessAt ?? t('webhooks.never') })}</div>
<div>{t('webhooks.lastFailure', { at: wh.lastFailureAt ?? t('webhooks.never') })}</div>
<div>{t('webhooks.failureCount', { count: wh.failureCount })}</div>
{wh.disabledReason && <div className="text-red-600">{wh.disabledReason}</div>}
</div>
{isEditingUrl ? (
<div className="flex gap-2">
<input
value={editUrlValue}
onChange={e => setEditUrlValue(e.target.value)}
placeholder={URL_PLACEHOLDER[wh.provider]}
data-testid={`webhook-edit-url-input-${wh.id}`}
className="h-8 flex-1 rounded-md border border-hairline px-2 text-xs"
/>
<button
type="button"
onClick={() => updateUrlMut.mutate({ id: wh.id, newUrl: editUrlValue.trim() })}
disabled={!editUrlValue.trim() || updateUrlMut.isPending}
className="text-xs text-accent hover:underline disabled:opacity-40"
>
{t('common:save')}
</button>
<button
type="button"
onClick={() => { setEditingUrlFor(null); setEditUrlValue(''); }}
className="text-xs text-slate-500 hover:underline"
>
{t('common:cancel')}
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => testMut.mutate(wh.id)}
disabled={!canManage || testMut.isPending}
data-testid={`webhook-test-${wh.id}`}
className="text-xs text-accent hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
>
{testMut.isPending ? t('webhooks.testing') : t('webhooks.test')}
</button>
{testResult[wh.id] === 'ok' && (
<span className="text-2xs text-green-600">{t('webhooks.testSent')}</span>
)}
{testResult[wh.id] === 'error' && (
<span className="text-2xs text-red-600">{t('webhooks.testFailedShort')}</span>
)}
{canManage && (
<button
type="button"
onClick={() => { setEditingUrlFor(wh.id); setEditUrlValue(''); }}
className="text-xs text-slate-600 hover:underline"
>
{t('webhooks.changeUrl')}
</button>
)}
{!wh.enabled && canManage && (
<button
type="button"
onClick={() => enableMut.mutate({ id: wh.id, enabled: true })}
disabled={enableMut.isPending}
data-testid={`webhook-reenable-${wh.id}`}
className="text-xs text-accent hover:underline disabled:opacity-40"
>
{t('webhooks.reEnable')}
</button>
)}
{wh.enabled && canManage && (
<button
type="button"
onClick={() => enableMut.mutate({ id: wh.id, enabled: false })}
disabled={enableMut.isPending}
className="text-xs text-slate-500 hover:underline disabled:opacity-40"
>
{t('webhooks.disable')}
</button>
)}
{canManage && (
<button
type="button"
onClick={() => deleteMut.mutate(wh.id)}
disabled={busy}
data-testid={`webhook-delete-${wh.id}`}
className="text-xs text-red-600 hover:text-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t('webhooks.remove')}
</button>
)}
</div>
)}
</div>
);
})}
</div>
)}
</section>
{!canManage && (
<p className="text-[13px] text-slate-400">{t('webhooks.readonly')}</p>
)}
</div>
</div>
);
}
+16
View File
@@ -12,6 +12,22 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
## 2026-07-10 — 「A2A 委任」設定が無効環境でエラーにならないようにしました
A2A を有効にしていないサーバーでは、設定の「A2A 委任」項目を開くと読み込みエラーが表示されていました。この項目は A2A が有効なときだけ意味を持つため、無効なサーバーでは設定サイドバーに表示しないようにしました(→[外部エージェント連携(A2A](./23-a2a.md))。
## 2026-07-10 — Webhook 通知が Microsoft Teams に対応し、Discord / Slack / Teams の3プロバイダが揃いました。
ワークスペースの Webhook 通知(設定 →「Webhook 通知」タブ)で、通知先に Microsoft Teams を選べるようになりました。追加フォームの「送信先」で Discord / Slack / Microsoft Teams を切り替え、Teams チャンネルの「コネクタ」から発行した従来の Incoming Webhook URL`*.webhook.office.com`)を登録するだけで、タスクの完了・失敗・回答待ちを Teams チャンネルへ流せます(→[通知を受け取る](./07-notifications.md))。
## 2026-07-10 — Webhook 通知が Slack に対応しました
ワークスペースの Webhook 通知(設定 →「Webhook 通知」タブ)で、通知先に Slack を選べるようになりました。追加フォームの「送信先」で Discord / Slack を切り替え、Slack の Incoming Webhook URL を登録するだけで、タスクの完了・失敗・回答待ちを Slack チャンネルへ流せます(→[通知を受け取る](./07-notifications.md))。
## 2026-07-09 — ワークスペース単位で Discord への Webhook 通知を設定できるようになりました
ワークスペースの設定画面に「Webhook 通知」タブが追加され、タスクの完了・失敗・回答待ちを Discord チャンネルへ通知できるようになりました(Slack / Microsoft Teams は今後対応予定)。編集権限を持つメンバーが Webhook URL を登録・削除・テスト送信でき、URL は保存後に画面上へ再表示されません。3 回連続で送信に失敗すると警告が表示され、10 回連続で自動的に無効化されます(→[通知を受け取る](./07-notifications.md))。
## 2026-07-09 — 右上のユーザー操作をアカウントメニューに集約し、パスワード変更を設定→Preferenceに移動しました
画面右上に横並びだったダークモード切替・ユーザー名・パスワード変更・ログアウトを、ユーザーアイコンをクリックして開く **アカウントメニュー** にまとめました。パスワード変更は [システム設定](./17-settings.md) の Preference セクションへ移動し、Google / Gitea などの外部ログインアカウントでは変更不可であることが分かるように案内が表示されます。
+43 -1
View File
@@ -3,7 +3,7 @@ id: notifications
title: 通知を受け取る
category: basic
order: 70
keywords: [通知, ブラウザ通知, Web Push, プッシュ通知, Service Worker, VAPID]
keywords: [通知, ブラウザ通知, Web Push, プッシュ通知, Service Worker, VAPID, Webhook, Discord, Slack, Teams, Microsoft Teams]
---
# 通知を受け取る
@@ -67,3 +67,45 @@ V2 が利用できる環境では、「通知にタスクの詳細(タイト
## 管理者向けの設定
V2(Web Push)の有効化には、サーバー側で `config.yaml``notifications.push.enabled: true` 設定と VAPID 鍵が必要です(鍵は初回起動時に自動生成されます)。サーバー側設定の概要は [設定](17-settings.md)、管理操作は [管理者向け](19-admin.md) を参照してください。
## ワークスペースの Webhook 通知(Discord / Slack / Microsoft Teams
上記の V1/V2 は **個人向け**(自分が owner のタスクのみ)の通知ですが、Webhook 通知は **ワークスペース単位の共有通知** です。チームが使っている Discord・Slack・Microsoft Teams のチャンネルへ、タスクの完了・失敗・回答待ちを流せます。
### 設定手順(Discord
1. ワークスペースを開き、設定 → 「Webhook 通知」タブを開く(編集権限のあるメンバーのみ操作可能)
2. 「送信先」で Discord を選ぶ
3. Discord のチャンネル設定 → 連携サービス から Incoming Webhook の URL を発行する
4. ラベル・発行した URL・通知したいイベント(完了 / 失敗 / 回答待ち)を入力して追加する
5. 「テスト送信」で疎通を確認する
### 設定手順(Slack
1. ワークスペースを開き、設定 → 「Webhook 通知」タブを開く(編集権限のあるメンバーのみ操作可能)
2. 「送信先」で Slack を選ぶ
3. Slack の [Incoming Webhooks](https://api.slack.com/messaging/webhooks) アプリをワークスペースに追加し、通知したいチャンネル向けの Webhook URL`https://hooks.slack.com/services/...`)を発行する
4. ラベル・発行した URL・通知したいイベント(完了 / 失敗 / 回答待ち)を入力して追加する
5. 「テスト送信」で疎通を確認する
### 設定手順(Microsoft Teams
1. ワークスペースを開き、設定 → 「Webhook 通知」タブを開く(編集権限のあるメンバーのみ操作可能)
2. 「送信先」で Microsoft Teams を選ぶ
3. 通知したい Teams チャンネルの「コネクタ」(Connectors)から従来の **Incoming Webhook** を追加し、URL`https://xxx.webhook.office.com/webhookb2/...`)を発行する
4. ラベル・発行した URL・通知したいイベント(完了 / 失敗 / 回答待ち)を入力して追加する
5. 「テスト送信」で疎通を確認する
> Microsoft は従来の Incoming Webhook コネクタ(MessageCard 形式)を段階的に非推奨化し、新しい「ワークフロー」(Power Automate、Adaptive Card 形式)への移行を進めています。現時点ではまだ広く使われている従来のコネクタ形式のみに対応しており、ワークフロー経由の URL は登録しても届きません。
通知本文にはデフォルトでタスクタイトル・piece 名・ワークスペース名・タスク ID・タスク詳細へのリンクが含まれます。「詳細を含める」を OFF にすると、これらを省いた最小限の通知になります。
### URL の扱い
Webhook URL は保存後に画面へ再表示されません(secret として暗号化保存)。URL を変更したい場合は「URL を変更」から新しい URL を再入力してください。
### 失敗時の挙動
- 送信に成功すると連続失敗回数は 0 に戻ります
- **3 回連続失敗** すると設定画面に警告バッジが表示されます
- **10 回連続失敗** すると自動的に無効化されます(「自動停止中」バッジが表示される)。編集権限のあるメンバーが「再有効化」するか、URL を再設定してください
+2
View File
@@ -38,6 +38,8 @@ OAuth2 の標準フロー(認可コード + PKCE)をベースにしており
### 設定 → A2A 委任 から一覧・取り消し
この項目は A2A が有効なサーバーでのみ表示されます。管理者が `a2a.enabled: true` を設定していない場合、設定のサイドバーに「A2A 委任」は現れません。
**設定 → A2A 委任** を開くと、自分が過去に承認した委任の一覧が表示されます。各行には次の情報が確認できます。
| 項目 | 内容 |
+5
View File
@@ -11,6 +11,11 @@ export interface SetupStatus {
deployHint: 'docker' | 'source';
/** True when a setup token must be supplied with mutating setup calls. */
tokenRequired: boolean;
/**
* True when A2A is enabled server-side. Drives whether the per-user A2A
* Delegations settings section is shown (its API route only exists when on).
*/
a2aEnabled?: boolean;
}
/**
+51
View File
@@ -365,6 +365,7 @@
"browser": "Browser",
"tools": "Tools",
"python": "Python",
"webhooks": "Webhooks",
"members": "Members"
},
"pieces": {
@@ -418,5 +419,55 @@
"addFailed": "Add failed: {{msg}}",
"removed": "Package removed.",
"removeFailed": "Remove failed: {{msg}}"
},
"webhooks": {
"heading": "Webhook notifications",
"intro": "Send task completed / failed / waiting-for-you notifications to a Discord, Slack, or Microsoft Teams channel. The webhook URL is never shown again after it's saved.",
"addLabel": "Add a webhook",
"provider": {
"discord": "Discord",
"slack": "Slack",
"teams": "Microsoft Teams"
},
"labelPlaceholder": "e.g. #dev-channel",
"urlHint": {
"discord": "Create an Incoming Webhook URL from Discord's channel Integrations settings and paste it here. The URL will not be shown again after saving.",
"slack": "Create an Incoming Webhook URL from Slack's Incoming Webhooks app and paste it here. The URL will not be shown again after saving.",
"teams": "Create a classic Incoming Webhook URL (*.webhook.office.com) from a Teams channel's Connectors settings and paste it here. The URL will not be shown again after saving. URLs from the newer Workflows (Power Automate) connector are not supported."
},
"event": {
"succeeded": "Succeeded",
"failed": "Failed",
"waiting_human": "Waiting for you"
},
"includeDetails": "Include details (task title, piece name, workspace name)",
"add": "Add",
"adding": "Adding…",
"listHeading": "Registered webhooks",
"none": "Nothing added yet.",
"remove": "Remove",
"disable": "Disable",
"reEnable": "Re-enable",
"changeUrl": "Change URL",
"test": "Send test",
"testing": "Sending…",
"testSent": "Test notification sent.",
"testFailed": "Test send failed: {{msg}}",
"testFailedShort": "Send failed",
"urlUpdated": "URL updated.",
"detailsOff": "no details",
"lastSuccess": "Last success: {{at}}",
"lastFailure": "Last failure: {{at}}",
"failureCount": "Consecutive failures: {{count}}",
"never": "never",
"autoDisabledBadge": "Auto-disabled",
"warningBadge": "{{count}} consecutive failures",
"readonly": "Only this workspace's owner or an admin can edit.",
"fetchError": "Failed to load webhooks: {{msg}}",
"added": "Webhook added.",
"addFailed": "Add failed: {{msg}}",
"removed": "Webhook removed.",
"removeFailed": "Remove failed: {{msg}}",
"updateFailed": "Update failed: {{msg}}"
}
}
+51
View File
@@ -365,6 +365,7 @@
"browser": "ブラウザ",
"tools": "ツール",
"python": "Python",
"webhooks": "Webhook 通知",
"members": "メンバー"
},
"pieces": {
@@ -418,5 +419,55 @@
"addFailed": "追加に失敗しました: {{msg}}",
"removed": "パッケージを削除しました。",
"removeFailed": "削除に失敗しました: {{msg}}"
},
"webhooks": {
"heading": "Webhook 通知",
"intro": "タスクの完了・失敗・回答待ちを Discord / Slack / Microsoft Teams チャンネルに通知します。Webhook URL は保存後に再表示されません。",
"addLabel": "Webhook を追加",
"provider": {
"discord": "Discord",
"slack": "Slack",
"teams": "Microsoft Teams"
},
"labelPlaceholder": "例: #開発チャンネル",
"urlHint": {
"discord": "Discord の「連携サービス」からチャンネルの Incoming Webhook URL を発行し、貼り付けてください。URL は保存後に再表示されません。",
"slack": "Slack の「Incoming Webhooks」アプリからチャンネルの Webhook URL を発行し、貼り付けてください。URL は保存後に再表示されません。",
"teams": "Teams チャンネルの「コネクタ」から従来の Incoming Webhook URL*.webhook.office.com)を発行し、貼り付けてください。URL は保存後に再表示されません。新しい「ワークフロー」(Power Automate)経由の URL には対応していません。"
},
"event": {
"succeeded": "完了",
"failed": "失敗",
"waiting_human": "回答待ち"
},
"includeDetails": "詳細を含める(タスクタイトル・piece 名・ワークスペース名)",
"add": "追加",
"adding": "追加中…",
"listHeading": "登録済みの Webhook",
"none": "まだ何も登録されていません。",
"remove": "削除",
"disable": "無効化",
"reEnable": "再有効化",
"changeUrl": "URL を変更",
"test": "テスト送信",
"testing": "送信中…",
"testSent": "テスト通知を送信しました。",
"testFailed": "テスト送信に失敗しました: {{msg}}",
"testFailedShort": "送信失敗",
"urlUpdated": "URL を更新しました。",
"detailsOff": "詳細なし",
"lastSuccess": "最終成功: {{at}}",
"lastFailure": "最終失敗: {{at}}",
"failureCount": "連続失敗回数: {{count}}",
"never": "なし",
"autoDisabledBadge": "自動停止中",
"warningBadge": "連続失敗 {{count}} 回",
"readonly": "編集はこのワークスペースのオーナーまたは管理者のみ可能です。",
"fetchError": "Webhook 一覧の取得に失敗しました: {{msg}}",
"added": "Webhook を追加しました。",
"addFailed": "追加に失敗しました: {{msg}}",
"removed": "Webhook を削除しました。",
"removeFailed": "削除に失敗しました: {{msg}}",
"updateFailed": "更新に失敗しました: {{msg}}"
}
}
+18 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUrlState } from '../hooks/useUrlState';
import { useSetupState } from '../hooks/useSetupState';
import {
SettingsSidebar,
USER_SECTIONS,
@@ -15,6 +16,10 @@ interface SettingsPageProps {
export function SettingsPage({ isAdmin }: SettingsPageProps) {
const { t } = useTranslation('settings');
const { urlState, setUrlState } = useUrlState();
const { data: setup } = useSetupState();
// A2A is off by default; its delegations API route only mounts when enabled.
// Hide the section (and reject deep-links to it) unless the server says a2a is on.
const a2aEnabled = setup?.a2aEnabled === true;
// admin landing page: first LLM Workers (most-used setting). Non-admin
// lands on preferences. The pre-Step-3 default was 'provider'.
const fallbackSection = isAdmin ? 'llm-workers' : 'preferences';
@@ -22,9 +27,10 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
// Map legacy ids (provider / workspace / tools / browser-settings /
// search-filter / browser-sessions) into the new sidebar layout.
const requestedSection = LEGACY_SECTION_REDIRECT[rawRequested] ?? rawRequested;
const section = (!isAdmin && !USER_SECTIONS.includes(requestedSection))
? 'preferences'
: requestedSection;
const unavailableSection =
(!isAdmin && !USER_SECTIONS.includes(requestedSection)) ||
(requestedSection === 'a2a-delegations' && !a2aEnabled);
const section = unavailableSection ? 'preferences' : requestedSection;
// If the URL still carries a legacy id, rewrite it once so bookmarks
// and the Back button line up with the new navigation.
@@ -48,6 +54,14 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
}
}, [isAdmin, urlState.section, setUrlState]);
// a2a 無効時に a2a-delegations へ直リンクされたら preferences に正規化する。
// setup が解決してから判定する(有効サーバで初回 status 取得中に URL を書き換えないため)。
useEffect(() => {
if (setup && !a2aEnabled && urlState.section === 'a2a-delegations') {
setUrlState(prev => ({ ...prev, section: 'preferences' as any }));
}
}, [setup, a2aEnabled, urlState.section, setUrlState]);
const handleSelectSection = (s: string) => {
setUrlState(prev => ({ ...prev, section: s as any }));
setMobileView('detail');
@@ -63,6 +77,7 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
activeSection={section}
onSelectSection={handleSelectSection}
isAdmin={isAdmin}
a2aEnabled={a2aEnabled}
/>
</div>