diff --git a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.scss b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.scss deleted file mode 100644 index f7ab9f866..000000000 --- a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.scss +++ /dev/null @@ -1,184 +0,0 @@ -.bitfun-external-hooks { - &__body { - padding: var(--size-gap-4); - } - - &__state, - &__notice { - display: flex; - align-items: center; - gap: var(--size-gap-2); - color: var(--color-text-secondary); - font-size: var(--font-size-sm); - line-height: var(--line-height-base); - } - - &__notice { - padding: var(--size-gap-3); - border-radius: var(--size-radius-sm); - background: var(--element-bg-subtle); - } - - &__spinner { - animation: bitfun-external-hooks-spin 1s linear infinite; - } - - &__ecosystems, - &__sources { - display: flex; - flex-direction: column; - gap: var(--size-gap-3); - } - - &__ecosystem { - border: 1px solid var(--border-subtle); - border-radius: var(--size-radius-sm); - background: var(--element-bg-base); - overflow: hidden; - - > summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--size-gap-3); - padding: var(--size-gap-3); - color: var(--color-text-primary); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - cursor: pointer; - } - } - - &__counts, - &__location, - &__entry-detail, - &__empty-source, - &__footnote { - color: var(--color-text-secondary); - font-size: var(--font-size-xs); - font-weight: var(--font-weight-normal); - } - - &__sources { - padding: 0 var(--size-gap-3) var(--size-gap-3); - } - - &__source { - padding: var(--size-gap-3); - border-radius: var(--size-radius-sm); - background: var(--element-bg-subtle); - } - - &__source-heading, - &__entry-main, - &__entry-detail { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--size-gap-3); - } - - &__source-name { - color: var(--color-text-primary); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - } - - &__location { - margin-top: var(--size-gap-1); - overflow-wrap: anywhere; - } - - &__health { - flex-shrink: 0; - padding: var(--size-gap-1) var(--size-gap-2); - border-radius: var(--size-radius-lg); - background: var(--element-bg-base); - color: var(--color-text-secondary); - font-size: var(--font-size-xs); - } - - &__entries, - &__diagnostics, - &__catalog-diagnostics ul { - margin: var(--size-gap-3) 0 0; - padding: 0; - list-style: none; - } - - &__entries li { - padding: var(--size-gap-2) 0; - border-top: 1px solid var(--border-subtle); - } - - &__entry-main { - font-size: var(--font-size-xs); - - code { - color: var(--color-text-primary); - font-family: var(--font-family-mono); - } - } - - &__entry-detail { - margin-top: var(--size-gap-1); - } - - &__empty-source, - &__diagnostics, - &__catalog-diagnostics, - &__footnote { - margin-top: var(--size-gap-3); - } - - &__diagnostics, - &__catalog-diagnostics { - color: var(--color-text-secondary); - font-size: var(--font-size-xs); - } - - &__diagnostics li, - &__catalog-diagnostics li { - margin-top: var(--size-gap-1); - } - - &__technical-detail { - margin-top: var(--size-gap-1); - - > summary { - cursor: pointer; - } - - > code, - > span { - display: block; - margin-top: var(--size-gap-1); - overflow-wrap: anywhere; - } - - > code { - font-family: var(--font-family-mono); - } - } -} - -@keyframes bitfun-external-hooks-spin { - to { transform: rotate(360deg); } -} - -@media (prefers-reduced-motion: reduce) { - .bitfun-external-hooks__spinner { - animation: none; - } -} - -@container config-panel (max-width: 520px) { - .bitfun-external-hooks { - &__ecosystem > summary, - &__source-heading, - &__entry-detail { - align-items: flex-start; - flex-direction: column; - } - } -} diff --git a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.test.tsx deleted file mode 100644 index 4e811a1ef..000000000 --- a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.test.tsx +++ /dev/null @@ -1,395 +0,0 @@ -// @vitest-environment jsdom - -import React, { act } from 'react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createRoot, type Root } from 'react-dom/client'; -import ExternalHooksPanel from './ExternalHooksPanel'; - -const getCatalogMock = vi.hoisted(() => vi.fn()); - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string, params?: Record) => ( - params ? `${key}:${JSON.stringify(params)}` : key - ), - }), -})); - -vi.mock('@/component-library', () => ({ - Button: ({ children, disabled, onClick, ...props }: React.ButtonHTMLAttributes) => ( - - ), - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, -})); - -vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ - externalHooksAPI: { getCatalog: getCatalogMock }, -})); - -vi.mock('./common', () => ({ - ConfigPageSection: ({ children, title, description, extra }: { - children: React.ReactNode; - title: string; - description?: React.ReactNode; - extra?: React.ReactNode; - }) => ( -
-

{title}

-
{description}
- {extra} - {children} -
- ), -})); - -const snapshot = { - schemaVersion: 1, - discoveryPending: false, - providers: [{ - providerId: 'claude-code.hooks', - ecosystemId: 'claude-code', - displayName: 'Claude Code Hooks', - }], - sources: [{ - key: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, - ecosystemId: 'claude-code', - displayName: 'Claude Code project Hooks', - sourceKind: 'settings', - scope: 'project', - locationHint: '.claude/settings.json', - health: 'available', - contentVersion: 'v1', - diagnostics: [], - }], - entries: [{ - stableKey: 'pre-tool-use', - source: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, - nativeEvent: 'PreToolUse', - matcher: { kind: 'pattern', display: 'Bash|Read' }, - handlerKind: 'command', - projectionStatus: 'mapped', - nativeActivation: 'unknown', - mapping: { hookPoint: 'tool_before' }, - contentVersion: 'v1', - }, { - stableKey: 'notification', - source: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, - nativeEvent: 'Notification', - matcher: { kind: 'any' }, - handlerKind: 'command', - projectionStatus: 'native_only', - nativeActivation: 'unknown', - contentVersion: 'v1', - }, { - stableKey: 'opaque', - source: { providerId: 'claude-code.hooks', sourceId: 'project-settings' }, - nativeEvent: '', - matcher: { kind: 'dynamic' }, - handlerKind: 'function', - projectionStatus: 'opaque', - nativeActivation: 'unknown', - contentVersion: 'v1', - }], - staleProviderIds: [], - failedProviderIds: [], - diagnostics: [], -}; - -describe('ExternalHooksPanel', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }) - .IS_REACT_ACT_ENVIRONMENT = true; - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - getCatalogMock.mockReset(); - getCatalogMock.mockResolvedValue(snapshot); - }); - - afterEach(() => { - vi.useRealTimers(); - act(() => root.unmount()); - container.remove(); - }); - - it('renders mapped and native-only Hooks without executable handler details', async () => { - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('PreToolUse'); - expect(container.textContent).toContain('hooks.projection.tool_before'); - expect(container.textContent).toContain('Notification'); - expect(container.textContent).toContain('hooks.projection.nativeOnly'); - expect(container.textContent).toContain('hooks.projection.opaque'); - expect(container.textContent).toContain('hooks.matcherValue.all'); - expect(container.textContent).not.toContain('curl'); - expect(getCatalogMock).toHaveBeenCalledWith('D:/workspace/project', true); - }); - - it('shows source diagnostics once and keeps provider diagnostics separate', async () => { - const sourceDiagnostic = { - severity: 'warning', - assetKind: 'hook', - code: 'claude.hook.partial', - message: 'source-only diagnostic', - source: snapshot.sources[0].key, - }; - getCatalogMock.mockResolvedValue({ - ...snapshot, - sources: [{ ...snapshot.sources[0], diagnostics: [sourceDiagnostic] }], - diagnostics: [ - sourceDiagnostic, - { - severity: 'info', - assetKind: 'hook', - code: 'claude.hook.coverage', - message: 'provider-wide diagnostic', - }, - ], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(container.textContent?.match(/source-only diagnostic/g)).toHaveLength(1); - expect(container.textContent?.match(/provider-wide diagnostic/g)).toHaveLength(1); - }); - - it('shows a remote-specific unsupported state without reading local Hooks', async () => { - await act(async () => { - root.render( - , - ); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('hooks.unsupported.remote'); - expect(getCatalogMock).not.toHaveBeenCalled(); - }); - - it('refreshes explicitly without exposing edit or execute controls', async () => { - await act(async () => { - root.render(); - await Promise.resolve(); - }); - const refresh = container.querySelector('button[aria-label="hooks.refresh"]'); - expect(refresh).not.toBeNull(); - await act(async () => { - refresh?.click(); - await Promise.resolve(); - }); - - expect(getCatalogMock).toHaveBeenLastCalledWith('D:/workspace/project', true); - expect(container.querySelector('[data-hook-action="edit"]')).toBeNull(); - expect(container.querySelector('[data-hook-action="execute"]')).toBeNull(); - }); - - it('responds to the shared page refresh epoch', async () => { - await act(async () => { - root.render(); - await Promise.resolve(); - }); - getCatalogMock.mockClear(); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(getCatalogMock).toHaveBeenCalledWith('D:/workspace/project', true); - }); - - it('bounds mounted Hook entries until the user asks for more', async () => { - const entries = Array.from({ length: 250 }, (_, index) => ({ - ...snapshot.entries[0], - stableKey: `entry-${index}`, - nativeEvent: `Event${index}`, - })); - getCatalogMock.mockResolvedValue({ ...snapshot, entries }); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(container.querySelectorAll('.bitfun-external-hooks__entries > li')).toHaveLength(100); - const showMore = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent?.includes('hooks.showMoreEntries'), - ); - expect(showMore).toBeDefined(); - await act(async () => showMore?.dispatchEvent(new MouseEvent('click', { bubbles: true }))); - expect(container.querySelectorAll('.bitfun-external-hooks__entries > li')).toHaveLength(200); - }); - - it('uses one entry budget across every visible source in a provider', async () => { - const sources = Array.from({ length: 20 }, (_, sourceIndex) => ({ - ...snapshot.sources[0], - key: { providerId: 'claude-code.hooks', sourceId: `source-${sourceIndex}` }, - displayName: `Source ${sourceIndex}`, - })); - const entries = sources.flatMap((source, sourceIndex) => ( - Array.from({ length: 100 }, (_, entryIndex) => ({ - ...snapshot.entries[0], - stableKey: `entry-${sourceIndex}-${entryIndex}`, - source: source.key, - nativeEvent: `Event${sourceIndex}_${entryIndex}`, - })) - )); - getCatalogMock.mockResolvedValue({ ...snapshot, sources, entries }); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(container.querySelectorAll('.bitfun-external-hooks__entries > li')).toHaveLength(100); - expect(container.querySelector('button[aria-label*="Claude Code Hooks"]')).not.toBeNull(); - }); - - it('keeps empty providers visible and announces the final empty state', async () => { - getCatalogMock.mockResolvedValue({ ...snapshot, sources: [], entries: [] }); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('Claude Code Hooks'); - expect(container.textContent).toContain('hooks.providerEmpty'); - expect(container.querySelector('[aria-live="polite"]')?.textContent).toContain('hooks.empty'); - }); - - it('distinguishes failed empty providers from successful empty discovery', async () => { - getCatalogMock.mockResolvedValue({ - ...snapshot, - sources: [], - entries: [], - failedProviderIds: ['claude-code.hooks'], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('hooks.providerFailed'); - expect(container.textContent).toContain('hooks.health.failed'); - expect(container.textContent).not.toContain('hooks.providerEmpty'); - }); - - it('does not announce a stale empty catalog as a successful empty discovery', async () => { - getCatalogMock.mockResolvedValue({ - ...snapshot, - sources: [], - entries: [], - staleProviderIds: ['claude-code.hooks'], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('hooks.providerStaleEmpty'); - expect(container.textContent).toContain('hooks.health.stale'); - expect(container.textContent).not.toContain('hooks.empty'); - }); - - it('bounds provider and catalog diagnostics until the user asks for more', async () => { - const providerDiagnostics = Array.from({ length: 30 }, (_, index) => ({ - severity: 'warning', - assetKind: 'hook', - code: `claude.hook.source_${index}`, - message: `source diagnostic ${index}`, - source: snapshot.sources[0].key, - })); - const catalogDiagnostics = Array.from({ length: 30 }, (_, index) => ({ - severity: 'warning', - assetKind: 'hook', - code: `claude.hook.catalog_${index}`, - message: `catalog diagnostic ${index}`, - })); - getCatalogMock.mockResolvedValue({ - ...snapshot, - sources: [{ ...snapshot.sources[0], diagnostics: providerDiagnostics }], - diagnostics: [...providerDiagnostics, ...catalogDiagnostics], - }); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - - expect(container.querySelectorAll('.bitfun-external-hooks__diagnostics > li')).toHaveLength(20); - expect(container.querySelectorAll('.bitfun-external-hooks__catalog-diagnostics li')).toHaveLength(20); - expect(Array.from(container.querySelectorAll('button')).filter( - (button) => button.textContent?.includes('hooks.showMoreDiagnostics'), - )).toHaveLength(2); - }); - - it('polls the cached snapshot until deferred discovery completes', async () => { - vi.useFakeTimers(); - getCatalogMock - .mockResolvedValueOnce({ ...snapshot, discoveryPending: true, sources: [], entries: [] }) - .mockResolvedValueOnce({ ...snapshot, discoveryPending: true, sources: [], entries: [] }) - .mockResolvedValueOnce(snapshot); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - expect(container.textContent).toContain('hooks.pending'); - - await act(async () => { - await vi.advanceTimersByTimeAsync(250); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('hooks.pending'); - - await act(async () => { - await vi.advanceTimersByTimeAsync(250); - await Promise.resolve(); - }); - - expect(getCatalogMock).toHaveBeenLastCalledWith('D:/workspace/project', false); - expect(container.textContent).toContain('PreToolUse'); - }); - - it('stops failed polling and allows an explicit refresh to recover', async () => { - vi.useFakeTimers(); - getCatalogMock - .mockResolvedValueOnce({ ...snapshot, discoveryPending: true, sources: [], entries: [] }) - .mockRejectedValueOnce(new Error('transport unavailable')) - .mockResolvedValueOnce(snapshot); - - await act(async () => { - root.render(); - await Promise.resolve(); - }); - await act(async () => { - await vi.advanceTimersByTimeAsync(250); - await Promise.resolve(); - }); - - expect(container.textContent).toContain('hooks.pollInterrupted'); - expect(container.textContent).not.toContain('hooks.staleAfterRefresh'); - expect(container.textContent).not.toContain('hooks.providerEmpty'); - const refresh = container.querySelector('button[aria-label="hooks.refresh"]'); - expect(refresh?.disabled).toBe(false); - await act(async () => { - refresh?.click(); - await Promise.resolve(); - }); - - expect(getCatalogMock).toHaveBeenLastCalledWith('D:/workspace/project', true); - expect(container.textContent).toContain('PreToolUse'); - }); -}); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.tsx b/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.tsx deleted file mode 100644 index d4a935c5e..000000000 --- a/src/web-ui/src/infrastructure/config/components/ExternalHooksPanel.tsx +++ /dev/null @@ -1,517 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { TFunction } from 'i18next'; -import { AlertTriangle, CircleDashed, RefreshCw } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import { Button, Tooltip } from '@/component-library'; -import { ExternalSourceApiError } from '@/infrastructure/api/service-api/ExternalSourcesAPI'; -import { - externalHooksAPI, - type ExternalHookCatalogEntry, - type ExternalHookCatalogSnapshot, - type ExternalHookDiagnostic, - type ExternalHookProviderIdentity, - type ExternalHookSource, -} from '@/infrastructure/api/service-api/ExternalHooksAPI'; -import { createLogger } from '@/shared/utils/logger'; -import { ConfigPageSection } from './common'; -import './ExternalHooksPanel.scss'; - -const logger = createLogger('ExternalHooksPanel'); -const INITIAL_SOURCE_LIMIT = 20; -const SOURCE_PAGE_SIZE = 20; -const INITIAL_ENTRY_LIMIT = 100; -const ENTRY_PAGE_SIZE = 100; -const INITIAL_DIAGNOSTIC_LIMIT = 20; -const DIAGNOSTIC_PAGE_SIZE = 20; - -export interface ExternalHooksPanelProps { - workspacePath?: string; - unsupportedReason?: 'remote' | 'peer'; - refreshEpoch?: number; -} - -function sourceKey(source: ExternalHookSource['key']): string { - return `${source.providerId}\u0000${source.sourceId}`; -} - -function sourceScopeLabel(source: ExternalHookSource, t: TFunction): string { - return source.scope === 'workspace_local' - ? t('shared:features.workspace') - : t(`hooks.scope.${source.scope}`); -} - -function matcherLabel(entry: ExternalHookCatalogEntry, t: TFunction): string { - switch (entry.matcher.kind) { - case 'any': return t('hooks.matcherValue.all'); - case 'pattern': return entry.matcher.display; - case 'dynamic': return t('hooks.matcherValue.dynamic'); - case 'unavailable': return t('hooks.matcherValue.unavailable'); - default: return t('hooks.matcherValue.unknown'); - } -} - -function projectionLabel(entry: ExternalHookCatalogEntry, t: TFunction): string { - if (entry.projectionStatus === 'opaque') return t('hooks.projection.opaque'); - if (!entry.mapping) return t('hooks.projection.nativeOnly'); - return t(`hooks.projection.${entry.mapping.hookPoint}`); -} - -function diagnosticCategory(code: string): string { - if (code.endsWith('all_disabled')) return 'nativeDisabled'; - if (code.endsWith('activation_not_evaluated')) return 'activationUnknown'; - if (code.endsWith('coverage_static_only') || code.endsWith('package_declared_only')) { - return 'staticCoverage'; - } - if (code.endsWith('registration_opaque')) return 'opaque'; - if (code.includes('limit') || code.endsWith('too_large')) return 'inspectionLimit'; - if (code.includes('parse') || code.includes('invalid')) return 'invalidConfiguration'; - if (code.includes('unreadable') || code.includes('failed') || code.includes('unavailable')) { - return 'unavailable'; - } - return 'generic'; -} - -function HookDiagnosticItem({ diagnostic, t }: { - diagnostic: ExternalHookDiagnostic; - t: TFunction; -}) { - return ( -
  • - {t(`hooks.diagnosticCategory.${diagnosticCategory(diagnostic.code)}`, { - code: diagnostic.code, - })} -
    - {t('hooks.technicalDetails')} - {diagnostic.code} - {diagnostic.message} -
    -
  • - ); -} - -function HookSourceCard({ - source, - entries, - diagnostics, - totalEntries, - stale, - t, -}: { - source: ExternalHookSource; - entries: ExternalHookCatalogEntry[]; - diagnostics: ExternalHookDiagnostic[]; - totalEntries: number; - stale: boolean; - t: TFunction; -}) { - return ( -
    -
    -
    -
    {source.displayName}
    -
    - {source.locationHint} - {' · '} - {sourceScopeLabel(source, t)} -
    -
    - - {t(stale ? 'hooks.health.stale' : `hooks.health.${source.health}`)} - -
    - {totalEntries === 0 ? ( -
    {t('hooks.noEntries')}
    - ) : entries.length === 0 ? ( -
    {t('hooks.entriesDeferred')}
    - ) : ( -
      - {entries.map((entry) => ( -
    • -
      - {entry.nativeEvent} - {t(`hooks.handler.${entry.handlerKind}`)} -
      -
      - {t('hooks.matcher', { matcher: matcherLabel(entry, t) })} - {projectionLabel(entry, t)} - {t(`hooks.activation.${entry.nativeActivation}`)} -
      -
    • - ))} -
    - )} - {diagnostics.length > 0 ? ( -
      - {diagnostics.map((diagnostic) => ( - - ))} -
    - ) : null} -
    - ); -} - -function HookProviderSection({ - provider, - sources, - entriesBySource, - staleProviderIds, - failedProviderIds, - discoveryPending, - initiallyOpen, - t, -}: { - provider: ExternalHookProviderIdentity; - sources: ExternalHookSource[]; - entriesBySource: Map; - staleProviderIds: string[]; - failedProviderIds: string[]; - discoveryPending: boolean; - initiallyOpen: boolean; - t: TFunction; -}) { - const [open, setOpen] = useState(initiallyOpen); - const [sourceLimit, setSourceLimit] = useState(INITIAL_SOURCE_LIMIT); - const [entryLimit, setEntryLimit] = useState(INITIAL_ENTRY_LIMIT); - const [diagnosticLimit, setDiagnosticLimit] = useState(INITIAL_DIAGNOSTIC_LIMIT); - useEffect(() => { - if (initiallyOpen) setOpen(true); - }, [initiallyOpen]); - const entries = sources.flatMap((source) => entriesBySource.get(sourceKey(source.key)) ?? []); - const mappedCount = entries.filter((entry) => entry.projectionStatus === 'mapped').length; - const visibleSources = sources.slice(0, sourceLimit); - let remainingEntryBudget = entryLimit; - let remainingDiagnosticBudget = diagnosticLimit; - const renderedSources = visibleSources.map((source) => { - const sourceEntries = entriesBySource.get(sourceKey(source.key)) ?? []; - const renderedEntries = sourceEntries.slice(0, remainingEntryBudget); - const renderedDiagnostics = source.diagnostics.slice(0, remainingDiagnosticBudget); - remainingEntryBudget -= renderedEntries.length; - remainingDiagnosticBudget -= renderedDiagnostics.length; - return { - source, - entries: renderedEntries, - diagnostics: renderedDiagnostics, - totalEntries: sourceEntries.length, - }; - }); - const visibleEntryCount = visibleSources.reduce( - (count, source) => count + (entriesBySource.get(sourceKey(source.key))?.length ?? 0), - 0, - ); - const renderedEntryCount = renderedSources.reduce((count, item) => count + item.entries.length, 0); - const visibleDiagnosticCount = visibleSources.reduce( - (count, source) => count + source.diagnostics.length, - 0, - ); - const renderedDiagnosticCount = renderedSources.reduce( - (count, item) => count + item.diagnostics.length, - 0, - ); - const stale = staleProviderIds.includes(provider.providerId); - const failed = failedProviderIds.includes(provider.providerId); - return ( -
    setOpen(event.currentTarget.open)} - > - - {provider.displayName} - - {t('hooks.summary', { hooks: entries.length, mapped: mappedCount, sources: sources.length })} - {failed - ? ` · ${t('hooks.health.failed')}` - : stale ? ` · ${t('hooks.health.stale')}` : null} - - - {open ? ( -
    - {sources.length === 0 && !discoveryPending ? ( -
    - {t(failed ? 'hooks.providerFailed' : stale ? 'hooks.providerStaleEmpty' : 'hooks.providerEmpty')} -
    - ) : null} - {renderedSources.map(({ - source, - entries: renderedEntries, - diagnostics: renderedDiagnostics, - totalEntries, - }) => ( - - ))} - {visibleSources.length < sources.length ? ( - - ) : null} - {renderedEntryCount < visibleEntryCount ? ( - - ) : null} - {renderedDiagnosticCount < visibleDiagnosticCount ? ( - - ) : null} -
    - ) : null} -
    - ); -} - -const ExternalHooksPanel: React.FC = ({ - workspacePath, - unsupportedReason, - refreshEpoch = 0, -}) => { - const { t } = useTranslation(['settings/external-sources', 'shared']); - const [snapshot, setSnapshot] = useState(null); - const [loading, setLoading] = useState(!unsupportedReason); - const [refreshing, setRefreshing] = useState(false); - const [errorCode, setErrorCode] = useState(null); - const [catalogDiagnosticLimit, setCatalogDiagnosticLimit] = useState(INITIAL_DIAGNOSTIC_LIMIT); - const requestSequence = useRef(0); - - const loadCatalog = useCallback(async (forceRefresh: boolean, showLoading = true) => { - if (unsupportedReason) return null; - const sequence = ++requestSequence.current; - if (forceRefresh) setRefreshing(true); - if (showLoading) setLoading(true); - try { - const next = await externalHooksAPI.getCatalog(workspacePath, forceRefresh); - if (sequence !== requestSequence.current) return null; - setSnapshot(next); - setErrorCode(null); - return next; - } catch (error) { - if (sequence !== requestSequence.current) return null; - const code = error instanceof ExternalSourceApiError ? error.code : 'internal'; - logger.warn('Failed to load external Hook catalog', { code }); - setErrorCode(code); - return null; - } finally { - if (sequence === requestSequence.current) { - if (showLoading) setLoading(false); - setRefreshing(false); - } - } - }, [unsupportedReason, workspacePath]); - - useEffect(() => { - requestSequence.current += 1; - setSnapshot(null); - setErrorCode(null); - setCatalogDiagnosticLimit(INITIAL_DIAGNOSTIC_LIMIT); - if (unsupportedReason) { - setLoading(false); - setRefreshing(false); - return; - } - void loadCatalog(true); - // `refreshEpoch` deliberately joins initial/workspace and header refreshes. - }, [loadCatalog, refreshEpoch, unsupportedReason]); - - useEffect(() => { - if (unsupportedReason || loading || errorCode || !snapshot?.discoveryPending) return undefined; - let cancelled = false; - let timer: number | undefined; - const poll = async () => { - const next = await loadCatalog(false, false); - if (!cancelled && next?.discoveryPending) { - timer = window.setTimeout(() => void poll(), 250); - } - }; - timer = window.setTimeout(() => void poll(), 250); - return () => { - cancelled = true; - if (timer !== undefined) window.clearTimeout(timer); - }; - }, [errorCode, loadCatalog, loading, snapshot?.discoveryPending, unsupportedReason]); - - const providers = useMemo(() => (snapshot?.providers ?? []).map((provider) => ({ - provider, - sources: snapshot?.sources.filter((source) => source.key.providerId === provider.providerId) ?? [], - })), [snapshot]); - - const entriesBySource = useMemo(() => { - const grouped = new Map(); - for (const entry of snapshot?.entries ?? []) { - const key = sourceKey(entry.source); - const entries = grouped.get(key) ?? []; - entries.push(entry); - grouped.set(key, entries); - } - return grouped; - }, [snapshot]); - - const catalogDiagnostics = useMemo( - () => snapshot?.diagnostics.filter((diagnostic) => diagnostic.source === undefined) ?? [], - [snapshot], - ); - const visibleCatalogDiagnostics = catalogDiagnostics.slice(0, catalogDiagnosticLimit); - const busy = loading - || refreshing - || (Boolean(snapshot?.discoveryPending) && !errorCode); - const firstProviderWithSources = providers.findIndex((group) => group.sources.length > 0); - const refreshLabel = t(refreshing ? 'hooks.refreshing' : 'hooks.refresh'); - const refresh = unsupportedReason ? undefined : ( - - - - ); - - return ( - -
    - {unsupportedReason ? ( -
    -
    - ) : loading && !snapshot ? ( -
    -
    - ) : errorCode && !snapshot ? ( -
    -
    - ) : ( -
    - {errorCode ? ( -
    - {t( - snapshot?.discoveryPending - ? 'hooks.pollInterrupted' - : 'hooks.staleAfterRefresh', - { code: errorCode }, - )} -
    - ) : null} - {refreshing ? ( -
    - {t('hooks.refreshing')} -
    - ) : null} - {snapshot?.discoveryPending && !errorCode ? ( -
    - {t('hooks.pending')} -
    - ) : null} - {snapshot - && !snapshot.discoveryPending - && snapshot.sources.length === 0 - && (snapshot.failedProviderIds ?? []).length === 0 - && snapshot.staleProviderIds.length === 0 ? ( -
    - {t('hooks.empty')} -
    - ) : null} - {providers.map(({ provider, sources }, index) => ( - = 0 ? firstProviderWithSources : 0)} - t={t} - /> - ))} - {catalogDiagnostics.length > 0 ? ( -
    -
    {t('hooks.diagnostics')}
    -
      - {visibleCatalogDiagnostics.map((diagnostic) => ( - - ))} -
    - {visibleCatalogDiagnostics.length < catalogDiagnostics.length ? ( - - ) : null} -
    - ) : null} -
    {t('hooks.readOnly')}
    -
    - )} -
    -
    - ); -}; - -export default ExternalHooksPanel; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index 79e294aa8..7d33be32b 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -2,6 +2,37 @@ container-name: external-sources; container-type: inline-size; + details > summary { + cursor: pointer; + user-select: none; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + } + + details:not([class]) > summary { + display: inline-flex; + align-items: center; + + &::before { + content: ''; + flex-shrink: 0; + width: 5px; + height: 5px; + margin-right: var(--size-gap-1); + border-right: 2px solid var(--color-text-muted); + border-bottom: 2px solid var(--color-text-muted); + transform: rotate(-45deg); + transition: transform 0.15s var(--easing-standard); + } + } + + details:not([class])[open] > summary::before { + transform: rotate(45deg); + } + .bitfun-config-page-header__inner { position: relative; } @@ -67,7 +98,6 @@ display: inline-flex; align-items: center; width: fit-content; - border-radius: 999px; font-size: 10px; font-weight: 500; } @@ -97,7 +127,7 @@ &:focus-visible, &.is-active { color: var(--color-text-primary); - background: var(--color-bg-secondary); + background: color-mix(in srgb, var(--color-text-primary) 6%, transparent); } &:focus-visible { @@ -120,14 +150,14 @@ &__inherited-badge, &__override-badge, &__limited-badge { - padding: 2px 6px; - color: var(--color-text-secondary); - border: 1px solid var(--border-subtle); + padding: 0; + color: var(--color-text-muted); + border: 0; + background: transparent; } &__override-badge { color: var(--color-accent-500); - border-color: var(--color-accent-500); } &__policy-recovery { @@ -206,7 +236,7 @@ &:focus-visible, &[aria-expanded='true'] { color: var(--color-accent-500); - background: var(--color-bg-secondary); + background: color-mix(in srgb, var(--color-text-primary) 6%, transparent); } &:focus-visible { @@ -219,7 +249,9 @@ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; - padding: 0 0 4px; + padding: var(--size-gap-3) 0 4px; + border-top: 1px solid var(--border-subtle); + margin-top: var(--size-gap-2); } &__capability-row { @@ -267,7 +299,25 @@ } &__notice summary { + display: flex; + align-items: center; cursor: pointer; + + &::before { + content: ''; + flex-shrink: 0; + width: 5px; + height: 5px; + margin-right: var(--size-gap-1); + border-right: 2px solid var(--color-text-muted); + border-bottom: 2px solid var(--color-text-muted); + transform: rotate(-45deg); + transition: transform 0.15s var(--easing-standard); + } + } + + &__notice[open] summary::before { + transform: rotate(45deg); } &__recovery-actions { @@ -316,13 +366,14 @@ gap: var(--size-gap-2); min-height: 32px; padding: 4px 7px 4px 9px; - border: 1px solid var(--border-subtle); + border: 0; border-radius: var(--size-radius-sm); - background: var(--color-bg-tertiary); + background: transparent; cursor: pointer; + &:hover, &:focus-within { - border-color: var(--color-accent-500); + background: color-mix(in srgb, var(--color-text-primary) 4%, transparent); } .bitfun-switch__wrapper { @@ -372,11 +423,10 @@ display: inline-flex; align-items: center; min-height: 18px; - padding: 0 6px; - border: 1px solid var(--border-subtle); - border-radius: var(--size-radius-sm); - background: var(--color-bg-tertiary); - color: var(--color-text-secondary); + padding: 0; + border: 0; + background: transparent; + color: var(--color-text-muted); font-size: 11px; font-variant-numeric: tabular-nums; line-height: 16px; @@ -406,8 +456,8 @@ } &__tool-card { - padding: 10px 14px; - border-left: 2px solid var(--border-medium); + padding: 10px 0 10px 14px; + border-left: 2px solid var(--border-subtle); background: transparent; overflow-wrap: anywhere; @@ -441,10 +491,10 @@ } &__opencode-card { - padding: 12px 14px; - border: 1px solid var(--border-medium); - border-radius: 6px; - margin-bottom: 12px; + padding: 12px 0; + border-top: 1px solid var(--border-subtle); + border-radius: 0; + margin-bottom: 0; } &__opencode-summary { @@ -463,7 +513,7 @@ color: var(--color-text-secondary); font-size: 12px; - > div { + > * { display: flex; align-items: center; flex-wrap: wrap; @@ -473,6 +523,28 @@ &__source-detail-toggle { margin-top: 8px; + + > summary { + display: flex; + align-items: center; + cursor: pointer; + + &::before { + content: ''; + flex-shrink: 0; + width: 5px; + height: 5px; + margin-right: var(--size-gap-1); + border-right: 2px solid var(--color-text-muted); + border-bottom: 2px solid var(--color-text-muted); + transform: rotate(-45deg); + transition: transform 0.15s var(--easing-standard); + } + } + + &[open] > summary::before { + transform: rotate(45deg); + } } &__mcp-empty { @@ -480,7 +552,7 @@ font-size: 13px; display: grid; gap: 8px; - padding: 12px 14px; + padding: 12px 0; code { font-size: 12px; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 42b730e55..3b3d7fb88 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -561,7 +561,6 @@ describe('ExternalSourcesConfig', () => { button.textContent?.includes('OpenCode project commands')); expect(container.textContent).toContain('diagnostics.summary'); expect(container.textContent).toContain('diagnostics.category.invalidSettings'); - expect(container.textContent).toContain('One command file could not be parsed.'); expect(candidateButton).toBeDefined(); await act(async () => candidateButton?.click()); expect(setConflictChoiceMock).toHaveBeenCalledWith( @@ -1478,7 +1477,6 @@ describe('ExternalSourcesConfig', () => { 'agentDiagnostics.ignoredSetting.reason:{"setting":"agentDiagnostics.settings.temperature"}', ); expect(container.textContent).toContain('agentDiagnostics.invalidDefinition.reason'); - expect(container.textContent).toContain('opencode_agent_definition_type_invalid'); expect(container.textContent).toContain('agentConflicts.selectionApproves'); expect(container.textContent).toContain('.opencode/agents/explore.md'); expect(container.textContent).not.toContain('D:/workspace/project/.opencode/agents'); diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index cf690c65d..ccb13027f 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -50,7 +50,6 @@ import { type ExternalSourcePresentationGroup, } from '../externalSourcePresentation'; import { externalSourceRequestScopeKey } from './externalSourceRequestScope'; -import ExternalHooksPanel from './ExternalHooksPanel'; import './ExternalSourcesConfig.scss'; const DISCOVERY_POLL_DELAYS_MS = [750, 1_500, 3_000, 5_000] as const; @@ -286,7 +285,6 @@ const ExternalSourcesConfig: React.FC = () => { const peerDeviceId = peerDevice?.peerMode.active ? peerDevice.peerMode.deviceId : undefined; const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [hookRefreshEpoch, setHookRefreshEpoch] = useState(0); const [busyKey, setBusyKey] = useState(null); const [reviewingToolKey, setReviewingToolKey] = useState(null); const [reviewingAgentKey, setReviewingAgentKey] = useState(null); @@ -1161,7 +1159,6 @@ const ExternalSourcesConfig: React.FC = () => { aria-label={refreshing ? t('actions.refreshing') : t('actions.refresh')} disabled={refreshing || (!hostCapabilities.canRefresh && !error)} onClick={() => { - setHookRefreshEpoch((epoch) => epoch + 1); void loadSnapshot(true, true); }} > @@ -1171,11 +1168,6 @@ const ExternalSourcesConfig: React.FC = () => { )} /> - {hostUnavailable ? ( { ) : null}
    {t('common.technicalDetails')} -
    {error.detail}
    + {error.code ? ( +
    {t('operationErrors.errorCode', { code: error.code })}
    + ) : null} {error.stage ? (
    {t('operationErrors.stage', { stage: error.stage })}
    ) : null} @@ -1455,7 +1449,7 @@ const ExternalSourcesConfig: React.FC = () => {
    {
    {t('common.technicalDetails')} {diagnostic.code} -
    {diagnostic.message}
    ))} @@ -2260,7 +2252,6 @@ const ExternalSourcesConfig: React.FC = () => { · {t('mcp.emptyLocation.userGlobal')} · {t('mcp.emptyLocation.project')} · {t('mcp.emptyLocation.envConfig')} - {t('mcp.emptyExample')}
    ) : null} @@ -2364,10 +2355,9 @@ const ExternalSourcesConfig: React.FC = () => { return (
    {t(`agentDiagnostics.${category}.reason`, params)} - - {diagnostic.code} - - {t(`agentDiagnostics.${category}.nextStep`, params)} + {diagnostic.blocksActivation ? ( + {t(`agentDiagnostics.${category}.nextStep`, params)} + ) : null}
    ); })} @@ -2490,17 +2480,13 @@ const ExternalSourcesConfig: React.FC = () => { ); return ( - {t(`agentDiagnostics.${category}.reason`, params)}{' '} - {t(`agentDiagnostics.${category}.impact`, { - impact: diagnostic.blocksActivation - ? t('agentDiagnostics.activationBlocked') - : t('agentDiagnostics.degradedOnly'), - ...params, - })}{' '} - - {diagnostic.code} - {' '} - {t(`agentDiagnostics.${category}.nextStep`, params)} + {t(`agentDiagnostics.${category}.reason`, params)} + {diagnostic.blocksActivation ? ( + <> + {' '} + {t(`agentDiagnostics.${category}.nextStep`, params)} + + ) : null} ); })} @@ -2719,7 +2705,6 @@ const ExternalSourcesConfig: React.FC = () => {
    {t('common.technicalDetails')} {diagnostic.code} -
    {diagnostic.message}
    ))} diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index c33d1cc39..66bfad6bd 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -6,7 +6,7 @@ "checkingNonBlocking": "Checking for updates…", "hooks": { "title": "Hooks", - "description": "Inspect static Hook configuration from OpenCode, Claude Code, and Codex.", + "description": "View Hook configuration from OpenCode, Claude Code, and Codex. BitFun only reads config files without running any code.", "refresh": "Refresh Hooks", "refreshing": "Refreshing Hooks…", "showMoreSources": "Show {{count}} more sources", @@ -18,16 +18,16 @@ "technicalDetails": "Technical details", "loading": "Checking Hook configuration…", "pending": "Hook discovery is still running. The catalog will update automatically.", - "empty": "No supported Hook configuration was found.", + "empty": "No Hook configuration was found.", "error": "Hook configuration is unavailable ({{code}}).", - "staleAfterRefresh": "The refresh failed ({{code}}). Showing the last valid catalog.", - "pollInterrupted": "Automatic Hook discovery was interrupted ({{code}}). Refresh to retry; any visible catalog may be from the last successful check.", - "summary": "{{hooks}} Hooks · {{mapped}} semantic coverage matches · {{sources}} sources", - "providerEmpty": "No static Hook source was found for this application.", - "providerFailed": "Static Hook discovery failed for this application; no valid catalog is available.", - "providerStaleEmpty": "The last valid catalog is empty, and the latest refresh failed.", - "noEntries": "No statically identifiable Hook events were found in this source.", - "entriesDeferred": "More Hooks are available through the provider-level Show more action.", + "staleAfterRefresh": "The refresh failed ({{code}}). Showing the last successful result.", + "pollInterrupted": "Automatic Hook discovery was interrupted ({{code}}). Refresh to retry; any visible content may be from the last successful check.", + "summary": "{{hooks}} Hooks · {{mapped}} matchable · {{sources}} sources", + "providerEmpty": "No Hook configuration was found for this application.", + "providerFailed": "Failed to read Hook configuration for this application; no content is available.", + "providerStaleEmpty": "The last successful read found no Hooks, and the latest refresh also failed.", + "noEntries": "No Hook events were found in this source.", + "entriesDeferred": "More Hooks are available through the Show more button below.", "matcher": "Matcher: {{matcher}}", "matcherValue": { "all": "All", @@ -36,7 +36,7 @@ "unknown": "Unknown" }, "diagnostics": "Diagnostics", - "readOnly": "Read only. BitFun does not load or run handlers from this catalog.", + "readOnly": "Read only. BitFun only displays these configurations and does not run any Hooks.", "unsupported": { "remote": "Hook inspection is not available for remote workspaces in this version.", "peer": "Hook inspection is not available through Peer Device Mode in this version." @@ -46,8 +46,8 @@ "partial": "Partially read", "degraded": "Degraded", "unavailable": "Unavailable", - "stale": "Last valid result", - "failed": "Discovery failed" + "stale": "Last successful result", + "failed": "Read failed" }, "handler": { "function": "Function", @@ -66,21 +66,21 @@ "activation": { "disabled": "Disabled in native configuration", "unsupported": "Unsupported by the native runtime", - "unknown": "Native activation not evaluated" + "unknown": "Cannot confirm if enabled" }, "diagnosticCategory": { "nativeDisabled": "Hooks are disabled in this native configuration.", - "activationUnknown": "Native trust and activation require the source application's runtime.", - "staticCoverage": "This source is only partially visible through static inspection.", - "opaque": "Some registrations cannot be identified safely without executing source code.", + "activationUnknown": "BitFun only read the config files without running the source application, so it cannot confirm whether these Hooks are enabled. This is informational only and does not affect other features.", + "staticCoverage": "BitFun discovered some Hooks by reading the config files. The source application may register additional Hooks at runtime that only become visible when the app is actually running.", + "opaque": "Some Hook registration logic requires executing source code to identify, which static inspection cannot resolve.", "inspectionLimit": "Some Hook configuration was omitted by a static inspection safety limit.", "invalidConfiguration": "Some Hook configuration is invalid or could not be parsed.", "unavailable": "Some Hook configuration could not be read.", - "generic": "Hook inspection reported a diagnostic ({{code}})." + "generic": "Hook inspection reported a diagnostic." }, "projection": { - "tool_before": "Equivalent BitFun tool-before coverage (not connected)", - "tool_after": "Equivalent BitFun tool-after coverage (not connected)", + "tool_before": "Corresponds to BitFun tool-before", + "tool_after": "Corresponds to BitFun tool-after", "nativeOnly": "Native-only event", "opaque": "Opaque static registration" } @@ -207,6 +207,7 @@ "unavailableRetry": "Temporarily unavailable. Try again later.", "internal": "The action failed. Refresh to retry.", "referenceId": "Reference: {{id}}", + "errorCode": "Error code: {{code}}", "stage": "Stage: {{stage}}", "causationId": "Related operation: {{id}}" }, @@ -277,70 +278,70 @@ "invalid": "Configuration error" }, "agentDiagnostics": { - "activationBlocked": "This agent cannot be enabled yet.", - "degradedOnly": "Some settings will not apply in BitFun. Review the details to confirm the resulting behavior is acceptable.", + "activationBlocked": "This agent cannot be enabled.", + "degradedOnly": "Some settings will not apply.", "configurationUnavailable": { - "reason": "BitFun could not read its model settings.", + "reason": "BitFun could not read its model settings, so this agent cannot be enabled.", "impact": "{{impact}}", - "nextStep": "Open BitFun model settings, check that BitFun can read and save its settings, then refresh." + "nextStep": "Check BitFun model settings, then refresh." }, "modelUnavailable": { "reason": "The requested model is not available in BitFun.", "impact": "{{impact}}", - "nextStep": "Choose an available model in the source application, or set an available fixed Sub-Agent model in BitFun, then refresh." + "nextStep": "Choose an available model in the source application, or configure an available model in BitFun, then refresh." }, "toolUnavailable": { - "reason": "Unavailable tools: {{tools}}.", + "reason": "Unavailable tools: {{tools}}", "impact": "{{impact}}", - "nextStep": "Remove or replace the unsupported tools in the source application, then refresh." + "nextStep": "Remove or replace these tools in the source application, then refresh." }, "promptMissing": { - "reason": "The agent prompt is missing or empty.", + "reason": "This agent is missing a valid prompt.", "impact": "{{impact}}", - "nextStep": "Add a non-empty prompt in the source application, then refresh." + "nextStep": "Add a prompt in the source application, then refresh." }, "unsupportedSetting": { - "reason": "BitFun does not currently support this agent's {{setting}} setting.", + "reason": "BitFun does not support the {{setting}} setting, so this agent cannot be enabled.", "impact": "{{impact}}", - "nextStep": "Remove or change {{setting}} in the source application, then refresh." + "nextStep": "Remove that setting in the source application, then refresh." }, "ignoredSetting": { - "reason": "BitFun will not apply this agent's {{setting}} setting.", + "reason": "BitFun does not support the {{setting}} setting and will ignore it. This does not affect usage.", "impact": "{{impact}}", - "nextStep": "Review the effective model and tools above before enabling this agent." + "nextStep": "" }, "settings": { "unknownField": "unknown field", - "ambientPermissions": "global permission", - "permissions": "permission", + "ambientPermissions": "global permissions", + "permissions": "permissions", "options": "options", - "nativeAgentOverlay": "built-in agent override", + "nativeAgentOverlay": "built-in agent overlay", "legacyPrimaryMode": "legacy primary mode", "primaryAgentMode": "primary agent mode", - "toolPatterns": "tool wildcard", - "defaultPermissions": "default permission semantics", + "toolPatterns": "tool patterns", + "defaultPermissions": "default permissions", "variant": "model variant", "temperature": "temperature", "topP": "top_p", "steps": "step limit", - "maxSteps": "maxSteps limit", + "maxSteps": "max steps", "color": "display color", "primaryFacet": "primary-agent facet" }, "invalidDefinition": { - "reason": "The agent settings have an invalid or missing required value.", + "reason": "This agent has invalid configuration.", "impact": "{{impact}}", - "nextStep": "Correct the agent settings in the source application, then refresh." + "nextStep": "Fix the agent configuration in the source application, then refresh." }, "unsupportedBehavior": { - "reason": "This agent requires behavior or settings that BitFun does not support.", + "reason": "This agent uses settings that BitFun does not support, so it cannot be enabled.", "impact": "{{impact}}", - "nextStep": "Update the agent in the source application to use supported settings and include all required content, then refresh." + "nextStep": "Update the agent in the source application, then refresh." }, "ignoredOption": { - "reason": "BitFun does not apply this setting.", + "reason": "BitFun does not support this setting and will ignore it. This does not affect usage.", "impact": "{{impact}}", - "nextStep": "Before enabling, review the model and tools that will actually be used, and confirm that the result still meets your expectations." + "nextStep": "" } }, "agentConflicts": { @@ -376,13 +377,12 @@ "enable": "Enable server", "disable": "Disable", "empty": "No MCP servers found.", - "emptyGuidance": "BitFun discovers MCP servers from OpenCode-compatible configurations. Supported locations:", + "emptyGuidance": "BitFun looks for MCP servers in the following locations but found none:", "emptyLocation": { "userGlobal": "User global: ~/.config/opencode/opencode.json", "project": "Project: .opencode/opencode.json", "envConfig": "File pointed to by OPENCODE_CONFIG environment variable" }, - "emptyExample": "Add an \"mcp\" field to the config file, e.g.: { \"mcp\": { \"server-name\": { ... } } }", "transport": { "local_stdio": "Local process", "streamable_http": "Remote connection" diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index e7336a0fc..a9b13bb65 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -6,7 +6,7 @@ "checkingNonBlocking": "正在检查更新…", "hooks": { "title": "Hooks", - "description": "检查 OpenCode、Claude Code 和 Codex 的静态 Hook 配置。", + "description": "查看 OpenCode、Claude Code 和 Codex 的 Hook 配置。BitFun 只读取配置文件,不运行任何代码。", "refresh": "刷新 Hooks", "refreshing": "正在刷新 Hooks…", "showMoreSources": "再显示 {{count}} 个来源", @@ -18,16 +18,16 @@ "technicalDetails": "技术详情", "loading": "正在检查 Hook 配置…", "pending": "Hook 发现仍在进行,目录会自动更新。", - "empty": "未发现受支持的 Hook 配置。", + "empty": "未发现 Hook 配置。", "error": "Hook 配置当前不可用({{code}})。", - "staleAfterRefresh": "刷新失败({{code}}),正在显示最近一次有效目录。", - "pollInterrupted": "Hook 自动发现已中断({{code}})。请刷新重试;当前可见目录可能来自最近一次成功检查。", - "summary": "{{hooks}} 个 Hook · {{mapped}} 个等价语义覆盖 · {{sources}} 个来源", - "providerEmpty": "未发现此应用的静态 Hook 来源。", - "providerFailed": "此应用的静态 Hook 发现失败,当前没有可用目录。", - "providerStaleEmpty": "最近一次有效目录为空,且最新刷新失败。", - "noEntries": "此来源中没有可静态识别的 Hook 事件。", - "entriesDeferred": "更多 Hook 可通过提供方区域底部的“显示更多”查看。", + "staleAfterRefresh": "刷新失败({{code}}),正在显示最近一次成功读取的结果。", + "pollInterrupted": "Hook 自动发现已中断({{code}})。请刷新重试;当前可见内容可能来自最近一次成功检查。", + "summary": "{{hooks}} 个 Hook · {{mapped}} 个可匹配 · {{sources}} 个来源", + "providerEmpty": "未发现此应用的 Hook 配置。", + "providerFailed": "读取此应用的 Hook 配置失败,当前没有可用内容。", + "providerStaleEmpty": "上次成功读取时没有发现 Hook,且最新刷新也失败了。", + "noEntries": "此来源中没有发现 Hook 事件。", + "entriesDeferred": "更多 Hook 可通过下方的“显示更多”按钮查看。", "matcher": "匹配条件:{{matcher}}", "matcherValue": { "all": "全部", @@ -36,7 +36,7 @@ "unknown": "未知" }, "diagnostics": "诊断", - "readOnly": "仅供查看。BitFun 不会从此目录加载或运行处理器。", + "readOnly": "仅供查看。BitFun 只展示这些配置,不会运行其中的 Hook。", "unsupported": { "remote": "当前版本暂不支持检查远程工作区的 Hook。", "peer": "当前版本暂不支持通过 Peer Device Mode 检查 Hook。" @@ -46,8 +46,8 @@ "partial": "部分读取", "degraded": "已降级", "unavailable": "不可用", - "stale": "最近一次有效结果", - "failed": "发现失败" + "stale": "上次成功读取的结果", + "failed": "读取失败" }, "handler": { "function": "函数", @@ -66,21 +66,21 @@ "activation": { "disabled": "已在原生配置中禁用", "unsupported": "原生运行时不支持", - "unknown": "未评估原生激活状态" + "unknown": "无法确认是否已启用" }, "diagnosticCategory": { "nativeDisabled": "此原生配置已禁用 Hook。", - "activationUnknown": "信任与激活状态需要由来源应用运行时判断。", - "staticCoverage": "静态检查只能显示此来源的部分信息。", - "opaque": "部分注册无法在不执行源码的情况下安全识别。", + "activationUnknown": "BitFun 只读取了配置文件,没有运行来源应用,因此无法确认这些 Hook 是否已启用。这只是信息提示,不影响其他功能。", + "staticCoverage": "BitFun 通过读取配置文件发现了一部分 Hook。来源应用运行时可能还会注册更多 Hook,这些需要实际运行应用后才能看到。", + "opaque": "部分 Hook 注册逻辑需要执行源码才能识别,BitFun 静态检查无法解析。", "inspectionLimit": "部分 Hook 配置因静态检查安全限制而被省略。", "invalidConfiguration": "部分 Hook 配置无效或无法解析。", "unavailable": "部分 Hook 配置无法读取。", - "generic": "Hook 检查报告了一项诊断({{code}})。" + "generic": "Hook 检查报告了一项诊断。" }, "projection": { - "tool_before": "等价覆盖 BitFun 工具执行前(未连接运行时)", - "tool_after": "等价覆盖 BitFun 工具执行后(未连接运行时)", + "tool_before": "对应 BitFun 工具执行前", + "tool_after": "对应 BitFun 工具执行后", "nativeOnly": "仅来源应用支持", "opaque": "静态信息不完整" } @@ -207,6 +207,7 @@ "unavailableRetry": "暂时不可用,请稍后重试。", "internal": "操作失败,请刷新重试。", "referenceId": "参考编号:{{id}}", + "errorCode": "错误编号:{{code}}", "stage": "处理阶段:{{stage}}", "causationId": "关联操作:{{id}}" }, @@ -277,70 +278,70 @@ "invalid": "配置有误" }, "agentDiagnostics": { - "activationBlocked": "暂时无法启用此 Agent。", - "degradedOnly": "部分设置在 BitFun 中不会生效。请查看说明,确认实际结果符合预期。", + "activationBlocked": "无法启用此 Agent。", + "degradedOnly": "部分设置不会生效。", "configurationUnavailable": { - "reason": "BitFun 无法读取模型设置。", + "reason": "BitFun 无法读取模型设置,无法启用此 Agent。", "impact": "{{impact}}", - "nextStep": "请打开 BitFun 的模型设置,确认 BitFun 可以正常读取和保存设置,然后刷新。" + "nextStep": "请检查 BitFun 的模型设置后刷新。" }, "modelUnavailable": { - "reason": "该 Agent 请求的模型在 BitFun 中不可用。", + "reason": "此 Agent 请求的模型在 BitFun 中不可用。", "impact": "{{impact}}", - "nextStep": "请在来源应用中改用可用模型,或在 BitFun 中设置可用的固定 Sub-Agent 模型,然后刷新。" + "nextStep": "请在来源应用中更换模型,或在 BitFun 中配置可用模型后刷新。" }, "toolUnavailable": { - "reason": "BitFun 中不可用的工具:{{tools}}。", + "reason": "以下工具在 BitFun 中不可用:{{tools}}", "impact": "{{impact}}", - "nextStep": "请在来源应用中移除或替换不支持的工具,然后刷新。" + "nextStep": "请在来源应用中移除或替换这些工具后刷新。" }, "promptMissing": { - "reason": "该 Agent 缺少有效的 prompt。", + "reason": "此 Agent 缺少有效的 prompt。", "impact": "{{impact}}", - "nextStep": "请在来源应用中补充非空 prompt,然后刷新。" + "nextStep": "请在来源应用中补充 prompt 后刷新。" }, "unsupportedSetting": { - "reason": "BitFun 当前不支持该 Agent 的“{{setting}}”设置。", + "reason": "BitFun 不支持 {{setting}} 设置,无法启用此 Agent。", "impact": "{{impact}}", - "nextStep": "请在来源应用中移除或修改“{{setting}}”,然后刷新。" + "nextStep": "请在来源应用中移除该设置后刷新。" }, "ignoredSetting": { - "reason": "BitFun 不会应用该 Agent 的“{{setting}}”设置。", + "reason": "BitFun 不支持 {{setting}} 设置,会忽略它。不影响使用。", "impact": "{{impact}}", - "nextStep": "启用前,请核对上方实际使用的模型和工具。" + "nextStep": "" }, "settings": { "unknownField": "未知字段", "ambientPermissions": "全局权限", - "permissions": "permission 权限", - "options": "options", + "permissions": "权限", + "options": "选项", "nativeAgentOverlay": "内置 Agent 覆盖", - "legacyPrimaryMode": "旧版 primary 模式", - "primaryAgentMode": "primary Agent 模式", + "legacyPrimaryMode": "旧版主 Agent 模式", + "primaryAgentMode": "主 Agent 模式", "toolPatterns": "工具通配规则", - "defaultPermissions": "默认权限语义", - "variant": "模型 variant", - "temperature": "temperature 参数", + "defaultPermissions": "默认权限", + "variant": "模型变体", + "temperature": "温度参数", "topP": "top_p 参数", - "steps": "steps 步数限制", - "maxSteps": "maxSteps 步数限制", + "steps": "步数限制", + "maxSteps": "最大步数", "color": "显示颜色", - "primaryFacet": "primary Agent 能力" + "primaryFacet": "主 Agent 能力" }, "invalidDefinition": { - "reason": "该 Agent 的配置缺少必填内容或包含无效内容。", + "reason": "此 Agent 的配置有误。", "impact": "{{impact}}", - "nextStep": "请在原应用中修正 Agent 配置,然后刷新。" + "nextStep": "请在来源应用中修正配置后刷新。" }, "unsupportedBehavior": { - "reason": "该 Agent 需要 BitFun 当前不支持的行为或设置。", + "reason": "此 Agent 包含 BitFun 当前不支持的设置,无法启用。", "impact": "{{impact}}", - "nextStep": "请在原应用中改用受支持的设置并补全必填内容,然后刷新。" + "nextStep": "请在来源应用中修改后刷新。" }, "ignoredOption": { - "reason": "BitFun 不会应用这项设置。", + "reason": "BitFun 不支持这项设置,会忽略它。不影响使用。", "impact": "{{impact}}", - "nextStep": "启用前,请确认上方实际使用的模型和工具,并确认最终效果仍符合预期。" + "nextStep": "" } }, "agentConflicts": { @@ -376,13 +377,12 @@ "enable": "启用服务器", "disable": "停用", "empty": "未发现 MCP 服务器。", - "emptyGuidance": "BitFun 当前从 OpenCode 兼容配置中发现 MCP 服务器,支持以下配置位置:", + "emptyGuidance": "BitFun 从以下位置的配置中查找 MCP 服务器,但未找到任何配置:", "emptyLocation": { "userGlobal": "用户全局:~/.config/opencode/opencode.json", "project": "项目级:.opencode/opencode.json", "envConfig": "环境变量 OPENCODE_CONFIG 指向的文件" }, - "emptyExample": "在配置文件中添加 \"mcp\" 字段即可,例如:{ \"mcp\": { \"server-name\": { ... } } }", "transport": { "local_stdio": "本地进程", "streamable_http": "远程连接" diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 93f3b1832..05d78290f 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -6,7 +6,7 @@ "checkingNonBlocking": "正在檢查更新…", "hooks": { "title": "Hooks", - "description": "檢查 OpenCode、Claude Code 與 Codex 的靜態 Hook 設定。", + "description": "檢視 OpenCode、Claude Code 與 Codex 的 Hook 設定。BitFun 只讀取設定檔,不執行任何程式碼。", "refresh": "重新整理 Hooks", "refreshing": "正在重新整理 Hooks…", "showMoreSources": "再顯示 {{count}} 個來源", @@ -18,16 +18,16 @@ "technicalDetails": "技術詳情", "loading": "正在檢查 Hook 設定…", "pending": "Hook 探索仍在進行,目錄會自動更新。", - "empty": "未發現支援的 Hook 設定。", + "empty": "未發現 Hook 設定。", "error": "Hook 設定目前無法使用({{code}})。", - "staleAfterRefresh": "重新整理失敗({{code}}),正在顯示最近一次有效目錄。", - "pollInterrupted": "Hook 自動探索已中斷({{code}})。請重新整理重試;目前可見目錄可能來自最近一次成功檢查。", - "summary": "{{hooks}} 個 Hook · {{mapped}} 個等價語意涵蓋 · {{sources}} 個來源", - "providerEmpty": "未發現此應用的靜態 Hook 來源。", - "providerFailed": "此應用的靜態 Hook 探索失敗,目前沒有可用目錄。", - "providerStaleEmpty": "最近一次有效目錄為空,且最新重新整理失敗。", - "noEntries": "此來源中沒有可靜態識別的 Hook 事件。", - "entriesDeferred": "更多 Hook 可透過提供者區域底部的「顯示更多」查看。", + "staleAfterRefresh": "重新整理失敗({{code}}),正在顯示最近一次成功讀取的結果。", + "pollInterrupted": "Hook 自動探索已中斷({{code}})。請重新整理重試;目前可見內容可能來自最近一次成功檢查。", + "summary": "{{hooks}} 個 Hook · {{mapped}} 個可匹配 · {{sources}} 個來源", + "providerEmpty": "未發現此應用的 Hook 設定。", + "providerFailed": "讀取此應用的 Hook 設定失敗,目前沒有可用內容。", + "providerStaleEmpty": "上次成功讀取時沒有發現 Hook,且最新重新整理也失敗了。", + "noEntries": "此來源中沒有發現 Hook 事件。", + "entriesDeferred": "更多 Hook 可透過下方的「顯示更多」按鈕查看。", "matcher": "比對條件:{{matcher}}", "matcherValue": { "all": "全部", @@ -36,7 +36,7 @@ "unknown": "未知" }, "diagnostics": "診斷", - "readOnly": "僅供檢視。BitFun 不會從此目錄載入或執行處理器。", + "readOnly": "僅供檢視。BitFun 只展示這些設定,不會執行其中的 Hook。", "unsupported": { "remote": "目前版本暫不支援檢查遠端工作區的 Hook。", "peer": "目前版本暫不支援透過 Peer Device Mode 檢查 Hook。" @@ -46,8 +46,8 @@ "partial": "部分讀取", "degraded": "已降級", "unavailable": "無法使用", - "stale": "最近一次有效結果", - "failed": "探索失敗" + "stale": "上次成功讀取的結果", + "failed": "讀取失敗" }, "handler": { "function": "函式", @@ -66,21 +66,21 @@ "activation": { "disabled": "已在原生設定中停用", "unsupported": "原生執行階段不支援", - "unknown": "未評估原生啟用狀態" + "unknown": "無法確認是否已啟用" }, "diagnosticCategory": { "nativeDisabled": "此原生設定已停用 Hook。", - "activationUnknown": "信任與啟用狀態需要由來源應用程式執行階段判斷。", - "staticCoverage": "靜態檢查只能顯示此來源的部分資訊。", - "opaque": "部分註冊無法在不執行原始碼的情況下安全識別。", + "activationUnknown": "BitFun 只讀取了設定檔,沒有執行來源應用程式,因此無法確認這些 Hook 是否已啟用。這只是資訊提示,不影響其他功能。", + "staticCoverage": "BitFun 透過讀取設定檔發現了一部分 Hook。來源應用程式執行階段可能還會註冊更多 Hook,這些需要實際執行應用程式後才能看到。", + "opaque": "部分 Hook 註冊邏輯需要執行原始碼才能識別,BitFun 靜態檢查無法解析。", "inspectionLimit": "部分 Hook 設定因靜態檢查安全限制而被省略。", "invalidConfiguration": "部分 Hook 設定無效或無法解析。", "unavailable": "部分 Hook 設定無法讀取。", - "generic": "Hook 檢查回報了一項診斷({{code}})。" + "generic": "Hook 檢查回報了一項診斷。" }, "projection": { - "tool_before": "等價涵蓋 BitFun 工具執行前(未連接執行階段)", - "tool_after": "等價涵蓋 BitFun 工具執行後(未連接執行階段)", + "tool_before": "對應 BitFun 工具執行前", + "tool_after": "對應 BitFun 工具執行後", "nativeOnly": "僅來源應用支援", "opaque": "靜態資訊不完整" } @@ -207,6 +207,7 @@ "unavailableRetry": "暫時無法使用,請稍後重試。", "internal": "操作失敗,請重新整理後重試。", "referenceId": "參考編號:{{id}}", + "errorCode": "錯誤編號:{{code}}", "stage": "處理階段:{{stage}}", "causationId": "關聯操作:{{id}}" }, @@ -277,70 +278,70 @@ "invalid": "設定有誤" }, "agentDiagnostics": { - "activationBlocked": "暫時無法啟用此 Agent。", - "degradedOnly": "部分設定在 BitFun 中不會生效。請查看說明,確認實際結果符合預期。", + "activationBlocked": "無法啟用此 Agent。", + "degradedOnly": "部分設定不會生效。", "configurationUnavailable": { - "reason": "BitFun 無法讀取模型設定。", + "reason": "BitFun 無法讀取模型設定,無法啟用此 Agent。", "impact": "{{impact}}", - "nextStep": "請開啟 BitFun 的模型設定,確認 BitFun 可以正常讀取與儲存設定,然後重新整理。" + "nextStep": "請檢查 BitFun 的模型設定後重新整理。" }, "modelUnavailable": { "reason": "此 Agent 要求的模型在 BitFun 中不可用。", "impact": "{{impact}}", - "nextStep": "請在來源應用中改用可用模型,或在 BitFun 中設定可用的固定 Sub-Agent 模型,然後重新整理。" + "nextStep": "請在來源應用中更換模型,或在 BitFun 中設定可用模型後重新整理。" }, "toolUnavailable": { - "reason": "BitFun 中不可用的工具:{{tools}}。", + "reason": "以下工具在 BitFun 中不可用:{{tools}}", "impact": "{{impact}}", - "nextStep": "請在來源應用中移除或替換不支援的工具,然後重新整理。" + "nextStep": "請在來源應用中移除或替換這些工具後重新整理。" }, "promptMissing": { "reason": "此 Agent 缺少有效的 prompt。", "impact": "{{impact}}", - "nextStep": "請在來源應用中補上非空白 prompt,然後重新整理。" + "nextStep": "請在來源應用中補上 prompt 後重新整理。" }, "unsupportedSetting": { - "reason": "BitFun 目前不支援此 Agent 的「{{setting}}」設定。", + "reason": "BitFun 不支援「{{setting}}」設定,無法啟用此 Agent。", "impact": "{{impact}}", - "nextStep": "請在來源應用中移除或修改「{{setting}}」,然後重新整理。" + "nextStep": "請在來源應用中移除該設定後重新整理。" }, "ignoredSetting": { - "reason": "BitFun 不會套用此 Agent 的「{{setting}}」設定。", + "reason": "BitFun 不支援「{{setting}}」設定,會忽略它。不影響使用。", "impact": "{{impact}}", - "nextStep": "啟用前,請核對上方實際使用的模型與工具。" + "nextStep": "" }, "settings": { "unknownField": "未知欄位", "ambientPermissions": "全域權限", - "permissions": "permission 權限", - "options": "options", + "permissions": "權限", + "options": "選項", "nativeAgentOverlay": "內建 Agent 覆寫", - "legacyPrimaryMode": "舊版 primary 模式", - "primaryAgentMode": "primary Agent 模式", - "toolPatterns": "工具萬用字元規則", - "defaultPermissions": "預設權限語意", - "variant": "模型 variant", - "temperature": "temperature 參數", + "legacyPrimaryMode": "舊版主 Agent 模式", + "primaryAgentMode": "主 Agent 模式", + "toolPatterns": "工具萬用規則", + "defaultPermissions": "預設權限", + "variant": "模型變體", + "temperature": "溫度參數", "topP": "top_p 參數", - "steps": "steps 步數限制", - "maxSteps": "maxSteps 步數限制", + "steps": "步數限制", + "maxSteps": "最大步數", "color": "顯示顏色", - "primaryFacet": "primary Agent 能力" + "primaryFacet": "主 Agent 能力" }, "invalidDefinition": { - "reason": "此 Agent 的設定缺少必填內容或包含無效內容。", + "reason": "此 Agent 的設定有誤。", "impact": "{{impact}}", - "nextStep": "請在原應用中修正 Agent 設定,然後重新整理。" + "nextStep": "請在來源應用中修正設定後重新整理。" }, "unsupportedBehavior": { - "reason": "此 Agent 需要 BitFun 目前不支援的行為或設定。", + "reason": "此 Agent 包含 BitFun 目前不支援的設定,無法啟用。", "impact": "{{impact}}", - "nextStep": "請在原應用程式中改用受支援的設定並補齊必填內容,然後重新整理。" + "nextStep": "請在來源應用中修改後重新整理。" }, "ignoredOption": { - "reason": "BitFun 不會套用這項設定。", + "reason": "BitFun 不支援這項設定,會忽略它。不影響使用。", "impact": "{{impact}}", - "nextStep": "啟用前,請確認上方實際使用的模型與工具,並確認最終結果仍符合預期。" + "nextStep": "" } }, "agentConflicts": { @@ -376,13 +377,12 @@ "enable": "啟用伺服器", "disable": "停用", "empty": "未發現 MCP 伺服器。", - "emptyGuidance": "BitFun 目前從 OpenCode 相容設定中發現 MCP 伺服器,支援以下設定位置:", + "emptyGuidance": "BitFun 從以下位置的設定中查找 MCP 伺服器,但未找到任何設定:", "emptyLocation": { "userGlobal": "使用者全域:~/.config/opencode/opencode.json", "project": "專案級:.opencode/opencode.json", "envConfig": "環境變數 OPENCODE_CONFIG 指向的檔案" }, - "emptyExample": "在設定檔中加入 \"mcp\" 欄位即可,例如:{ \"mcp\": { \"server-name\": { ... } } }", "transport": { "local_stdio": "本機程序", "streamable_http": "遠端連線"