From ccd5acb0fe05a49e562aa61c4f8b7362fc47a10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20de=20Vries?= Date: Thu, 2 Apr 2026 23:53:52 +0200 Subject: [PATCH 1/2] feat(history): replace backends.json path hints with history-sources.json Removes historyPathHints/cliHistoryPathHints from BackendDefinitionRecord and BackendSummary. Introduces a dedicated history-sources.json config file with read/write helpers and adds GET /api/history-sources + PATCH /api/history-sources/:provider routes. Updates the settings UI with a new History Sources section. Closes #77. --- backend/src/agents/config.ts | 39 +--- backend/src/agents/registry.test.ts | 33 ++- backend/src/agents/registry.ts | 65 +++--- backend/src/agents/types.ts | 12 +- backend/src/history/sources-config.test.ts | 186 +++++++++++++++++ backend/src/history/sources-config.ts | 133 ++++++++++++ backend/src/routes/agents.test.ts | 121 +++++++++-- backend/src/routes/agents.ts | 41 +++- frontend/src/hooks/useBackendSettings.ts | 111 +++++++--- frontend/src/router.test.tsx | 54 ++--- frontend/src/routes/settings.tsx | 230 +++++++++++---------- 11 files changed, 753 insertions(+), 272 deletions(-) create mode 100644 backend/src/history/sources-config.test.ts create mode 100644 backend/src/history/sources-config.ts diff --git a/backend/src/agents/config.ts b/backend/src/agents/config.ts index d779966..72e7c91 100644 --- a/backend/src/agents/config.ts +++ b/backend/src/agents/config.ts @@ -9,13 +9,6 @@ export interface BackendDefinitionRecord { commandCandidates: string[] command: string | null args: string[] - /** VS Code workspace storage root paths to search for history. */ - historyPathHints?: string[] - /** - * CLI session-state directory paths (WSL or Host) to search for history. - * Only meaningful for the `copilot` backend. - */ - cliHistoryPathHints?: string[] } interface BackendConfigFile { @@ -39,8 +32,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [ commandCandidates: ['copilot'], command: null, args: ['--acp'], - historyPathHints: [], - cliHistoryPathHints: [], }, { id: 'claude-code', @@ -49,7 +40,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [ commandCandidates: ['claude', 'claude-code'], command: null, args: ['--acp'], - historyPathHints: [], }, { id: 'gemini-cli', @@ -58,7 +48,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [ commandCandidates: ['gemini'], command: null, args: ['--acp'], - historyPathHints: [], }, { id: 'codex', @@ -67,7 +56,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [ commandCandidates: ['codex'], command: null, args: ['--acp'], - historyPathHints: [], }, { id: 'opencode', @@ -76,7 +64,6 @@ const DEFAULT_BACKENDS: BackendDefinitionRecord[] = [ commandCandidates: ['opencode'], command: null, args: ['acp'], - historyPathHints: [], }, ] @@ -96,9 +83,8 @@ export function readBackendConfig(): BackendDefinitionRecord[] { * Migrate old split Copilot backend records (copilot-cli-wsl, copilot-cli-host, * copilot-vscode-host, copilot-vscode-wsl) into a single `copilot` backend. * Any user-customized command/args from the CLI backends are preserved. - * All historyPathHints are merged into `historyPathHints` (VS Code roots). - * Paths that look like CLI session-state directories (absolute paths containing - * '.copilot' but not 'workspaceStorage') are also placed in `cliHistoryPathHints`. + * History path hints previously stored on these records are no longer part of + * `BackendDefinitionRecord` — they live in `history-sources.json` now. * Non-Copilot backends are unchanged. * Does not delete unknown custom user backends. */ @@ -118,21 +104,12 @@ function migrateLegacyCopilotBackends( return otherBackends } - // Merge legacy backends into a single copilot record + // Merge legacy backends into a single copilot record. // Prefer a backend with an explicit command (i.e., the CLI backend the user configured) // over a history-only record that only has commandCandidates but no resolved command. const cliBackend = legacyBackends.find((b) => b.command !== null) ?? legacyBackends.find((b) => b.commandCandidates.length > 0) - const allHints = Array.from( - new Set(legacyBackends.flatMap((b) => b.historyPathHints ?? []).filter(Boolean)) - ) - // CLI-specific roots: absolute paths that look like CLI session-state dirs (contain '.copilot'). - // Paths containing 'workspaceStorage' are VS Code workspace storage paths, not CLI dirs, - // so they must stay in historyPathHints only. - const cliHints = allHints.filter( - (p) => p.startsWith('/') && p.includes('.copilot') && !p.includes('workspaceStorage') - ) const enabled = legacyBackends.some((b) => b.enabled) const merged: BackendDefinitionRecord = { @@ -142,8 +119,6 @@ function migrateLegacyCopilotBackends( commandCandidates: cliBackend?.commandCandidates ?? ['copilot'], command: cliBackend?.command ?? null, args: cliBackend?.args ?? ['--acp'], - historyPathHints: allHints, - cliHistoryPathHints: cliHints, } return [merged, ...otherBackends] @@ -192,7 +167,7 @@ function normalizeBackendRecord(record: BackendDefinitionRecord): BackendDefinit return { id: record.id, name: record.name, - enabled: record.enabled ?? true, + enabled: (record as { enabled?: boolean }).enabled ?? true, commandCandidates: Array.isArray(record.commandCandidates) ? record.commandCandidates.filter((value): value is string => typeof value === 'string') : [], @@ -201,11 +176,5 @@ function normalizeBackendRecord(record: BackendDefinitionRecord): BackendDefinit args: Array.isArray(record.args) ? record.args.filter((value): value is string => typeof value === 'string') : [], - historyPathHints: Array.isArray(record.historyPathHints) - ? record.historyPathHints.filter((value): value is string => typeof value === 'string') - : [], - cliHistoryPathHints: Array.isArray(record.cliHistoryPathHints) - ? record.cliHistoryPathHints.filter((value): value is string => typeof value === 'string') - : [], } } diff --git a/backend/src/agents/registry.test.ts b/backend/src/agents/registry.test.ts index 5d2db84..218c0ee 100644 --- a/backend/src/agents/registry.test.ts +++ b/backend/src/agents/registry.test.ts @@ -11,6 +11,13 @@ const mergeSessionsMock = vi.fn((live, history) => [...history, ...live]) const readBackendConfigMock = vi.fn() const detectAvailableCommandMock = vi.fn(() => ({ command: null })) const getHistorySourceDescriptorsMock = vi.fn<(...args: unknown[]) => unknown[]>(() => []) +const getHistoryHintsForProviderMock = vi.fn( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_provider: string) => ({ + historyPathHints: [] as string[], + cliHistoryPathHints: [] as string[], + }) +) vi.mock('../projects/service.js', () => ({ listProjects: listProjectsMock, @@ -24,6 +31,10 @@ vi.mock('../history/index.js', () => ({ HISTORY_AGENT_IDS: new Set(['gemini-cli', 'copilot', 'opencode']), })) +vi.mock('../history/sources-config.js', () => ({ + getHistoryHintsForProvider: getHistoryHintsForProviderMock, +})) + vi.mock('./config.js', () => ({ createBackendId: vi.fn((value: string) => value), readBackendConfig: readBackendConfigMock, @@ -61,8 +72,6 @@ describe('AgentRegistry.listSessions', () => { commandCandidates: ['copilot'], command: 'copilot', args: ['--acp'], - historyPathHints: ['/tmp/copilot-vscode'], - cliHistoryPathHints: [], }, { id: 'gemini-cli', @@ -71,10 +80,16 @@ describe('AgentRegistry.listSessions', () => { commandCandidates: ['gemini'], command: null, args: ['--acp'], - historyPathHints: [], }, ]) + getHistoryHintsForProviderMock.mockImplementation((provider: string) => { + if (provider === 'copilot') + return { historyPathHints: ['/tmp/copilot-vscode'], cliHistoryPathHints: [] } + if (provider === 'gemini') return { historyPathHints: [], cliHistoryPathHints: [] } + return { historyPathHints: [], cliHistoryPathHints: [] } + }) + listProjectsMock.mockReturnValue([ { id: 'repo-1', @@ -267,10 +282,13 @@ describe('AgentRegistry.listBackends', () => { commandCandidates: ['opencode'], command: 'opencode', args: ['--acp'], - historyPathHints: [], }, ]) listProjectsMock.mockReturnValue([]) + getHistoryHintsForProviderMock.mockReturnValue({ + historyPathHints: [], + cliHistoryPathHints: [], + }) }) it('reports OpenCode history compatibility support', async () => { @@ -311,10 +329,14 @@ describe('AgentRegistry.listBackends', () => { commandCandidates: ['copilot'], command: null, args: ['--acp'], - historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'], }, ]) + getHistoryHintsForProviderMock.mockReturnValue({ + historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'], + cliHistoryPathHints: [], + }) + getHistorySourceDescriptorsMock.mockReturnValue([ { id: 'src-vscode', @@ -335,7 +357,6 @@ describe('AgentRegistry.listBackends', () => { expect(registry.listBackends()).toEqual([ expect.objectContaining({ id: 'copilot', - historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'], historySupport: { source: 'derived', supported: ['text', 'markdown', 'reasoning', 'tool_calls', 'truncation'], diff --git a/backend/src/agents/registry.ts b/backend/src/agents/registry.ts index 9f50ce6..103c9cd 100644 --- a/backend/src/agents/registry.ts +++ b/backend/src/agents/registry.ts @@ -27,6 +27,8 @@ import { getHistorySession, HISTORY_AGENT_IDS, } from '../history/index.js' +import { getHistoryHintsForProvider } from '../history/sources-config.js' +import type { HistoryProvider } from '../history/sources-config.js' import { FakeSessionAdapter } from '../testing/fakeAdapter.js' interface RegisteredAgent { @@ -36,8 +38,6 @@ interface RegisteredAgent { detectedCommand: string | null args: string[] defaultArgs: string[] - historyPathHints: string[] - cliHistoryPathHints: string[] enabled: boolean usesCustomCommand: boolean adapter?: SessionAdapter @@ -63,6 +63,17 @@ const UNKNOWN_ENDPOINT_SUPPORT = { ], } +/** Agent IDs that map to a `HistoryProvider` for path-hint lookups. */ +const HISTORY_PROVIDER_IDS = new Set(['copilot', 'gemini-cli', 'opencode']) + +/** Map from backend agent ID to the HistoryProvider key used in sources-config. */ +function toHistoryProvider(agentId: string): HistoryProvider | null { + if (agentId === 'copilot') return 'copilot' + if (agentId === 'gemini-cli') return 'gemini' + if (agentId === 'opencode') return 'opencode' + return null +} + export class AgentRegistry { private agents: RegisteredAgent[] @@ -99,8 +110,6 @@ export class AgentRegistry { detectedCommand: agent.detectedCommand, args: agent.args, defaultArgs: agent.args, - historyPathHints: agent.historyPathHints, - cliHistoryPathHints: agent.cliHistoryPathHints, enabled: agent.enabled, usesCustomCommand: agent.usesCustomCommand, endpointSupport, @@ -136,8 +145,6 @@ export class AgentRegistry { command?: string | null args?: string[] name?: string - historyPathHints?: string[] - cliHistoryPathHints?: string[] } ): BackendSummary { const config = readBackendConfig() @@ -154,12 +161,6 @@ export class AgentRegistry { enabled: input.enabled ?? current.enabled, command: normalizeCommand(input.command ?? current.command), args: Array.isArray(input.args) ? input.args.filter(Boolean) : current.args, - historyPathHints: Array.isArray(input.historyPathHints) - ? input.historyPathHints.filter(Boolean) - : current.historyPathHints, - cliHistoryPathHints: Array.isArray(input.cliHistoryPathHints) - ? input.cliHistoryPathHints.filter(Boolean) - : current.cliHistoryPathHints, } writeBackendConfig(config) @@ -179,11 +180,15 @@ export class AgentRegistry { const knownProjects = listProjects().map(toSessionProjectContext) const historySessions = listHistorySessions( knownProjects, - this.agents.map((agent) => ({ - id: agent.id, - historyPathHints: agent.historyPathHints, - cliHistoryPathHints: agent.cliHistoryPathHints, - })) + this.agents.map((agent) => { + const provider = toHistoryProvider(agent.id) + const hints = provider ? getHistoryHintsForProvider(provider) : null + return { + id: agent.id, + historyPathHints: hints?.historyPathHints ?? [], + cliHistoryPathHints: hints?.cliHistoryPathHints ?? [], + } + }) ) return mergeSessions(liveSessions, historySessions) @@ -199,11 +204,15 @@ export class AgentRegistry { return getHistorySession( sessionId, knownProjects, - this.agents.map((agent) => ({ - id: agent.id, - historyPathHints: agent.historyPathHints, - cliHistoryPathHints: agent.cliHistoryPathHints, - })), + this.agents.map((agent) => { + const provider = toHistoryProvider(agent.id) + const hints = provider ? getHistoryHintsForProvider(provider) : null + return { + id: agent.id, + historyPathHints: hints?.historyPathHints ?? [], + cliHistoryPathHints: hints?.cliHistoryPathHints ?? [], + } + }), agentId ) } @@ -291,8 +300,6 @@ export class AgentRegistry { detectedCommand, args: backend.args, defaultArgs: backend.args, - historyPathHints: backend.historyPathHints ?? [], - cliHistoryPathHints: backend.cliHistoryPathHints ?? [], enabled: backend.enabled, usesCustomCommand, adapter, @@ -344,11 +351,12 @@ export class AgentRegistry { } function getHistorySupport(agentId: string): HistorySupport { - const backend = readBackendConfig().find((entry) => entry.id === agentId) + const provider = toHistoryProvider(agentId) + const hints = provider ? getHistoryHintsForProvider(provider) : null const discoveredSources = getHistorySourceDescriptors( agentId, - backend?.historyPathHints ?? [], - backend?.cliHistoryPathHints ?? [] + hints?.historyPathHints ?? [], + hints?.cliHistoryPathHints ?? [] ) const discoverySummary = summarizeDiscoveredSources(discoveredSources) @@ -461,3 +469,6 @@ function uniqueBackendId(backends: BackendDefinitionRecord[], baseId: string): s export function createAgentRegistry(): AgentRegistry { return new AgentRegistry() } + +// Export for use from routes +export { HISTORY_PROVIDER_IDS, toHistoryProvider } diff --git a/backend/src/agents/types.ts b/backend/src/agents/types.ts index f3ec9d0..2428a4b 100644 --- a/backend/src/agents/types.ts +++ b/backend/src/agents/types.ts @@ -107,9 +107,6 @@ export interface BackendSummary extends AgentSummary { enabled: boolean args: string[] defaultArgs: string[] - historyPathHints: string[] - /** CLI session-state directory hints. Only used by the `copilot` backend. */ - cliHistoryPathHints: string[] detectedCommand: string | null usesCustomCommand: boolean endpointSupport: BackendEndpointSupport @@ -117,6 +114,15 @@ export interface BackendSummary extends AgentSummary { lastTestResult: BackendTestResult | null } +/** Shape returned by GET /api/history-sources and accepted by PATCH /api/history-sources/:provider. */ +export interface HistorySourceConfig { + provider: 'gemini' | 'copilot' | 'opencode' + /** VS Code workspace storage roots (or generic search roots for non-Copilot providers). */ + paths: string[] + /** CLI session-state directory paths. Only meaningful for the `copilot` provider. */ + cliPaths?: string[] +} + export interface BackendTestResult { ok: boolean message: string diff --git a/backend/src/history/sources-config.test.ts b/backend/src/history/sources-config.test.ts new file mode 100644 index 0000000..2f62fca --- /dev/null +++ b/backend/src/history/sources-config.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { + getHistoryHintsForProvider, + readHistorySourcesConfig, + updateHistorySource, + writeHistorySourcesConfig, +} from './sources-config.js' + +function makeTempDir(): string { + const dir = join(tmpdir(), `acp-sources-config-test-${Date.now()}-${Math.random()}`) + mkdirSync(dir, { recursive: true }) + return dir +} + +describe('sources-config', () => { + let tempDir: string + let origEnv: string | undefined + + beforeEach(() => { + tempDir = makeTempDir() + origEnv = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] + process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = join(tempDir, 'history-sources.json') + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + if (origEnv === undefined) { + delete process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] + } else { + process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = origEnv + } + }) + + describe('readHistorySourcesConfig', () => { + it('returns defaults when no config file exists', () => { + const result = readHistorySourcesConfig() + + expect(result).toHaveLength(3) + expect(result.map((r) => r.provider)).toEqual(['copilot', 'gemini', 'opencode']) + }) + + it('writes the defaults when the config file is missing', () => { + readHistorySourcesConfig() + + const path = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']! + expect(existsSync(path)).toBe(true) + }) + + it('reads configured sources from the file', () => { + const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']! + writeFileSync( + configPath, + JSON.stringify({ + sources: [ + { provider: 'copilot', paths: ['/a/b'], cliPaths: ['/c/d'] }, + { provider: 'gemini', paths: ['/e/f'] }, + ], + }), + 'utf8' + ) + + const result = readHistorySourcesConfig() + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ provider: 'copilot', paths: ['/a/b'], cliPaths: ['/c/d'] }) + expect(result[1]).toEqual({ provider: 'gemini', paths: ['/e/f'] }) + }) + + it('normalizes malformed paths arrays', () => { + const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']! + writeFileSync( + configPath, + JSON.stringify({ + sources: [{ provider: 'opencode', paths: [42, null, '/valid'] }], + }), + 'utf8' + ) + + const result = readHistorySourcesConfig() + expect(result[0]!.paths).toEqual(['/valid']) + }) + + it('returns defaults when file contains empty sources array', () => { + const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']! + writeFileSync(configPath, JSON.stringify({ sources: [] }), 'utf8') + + const result = readHistorySourcesConfig() + expect(result).toHaveLength(3) + }) + + it('returns defaults on invalid JSON', () => { + const configPath = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH']! + writeFileSync(configPath, 'not json', 'utf8') + + const result = readHistorySourcesConfig() + expect(result).toHaveLength(3) + }) + }) + + describe('writeHistorySourcesConfig', () => { + it('writes sources to JSON file', () => { + writeHistorySourcesConfig([ + { provider: 'copilot', paths: ['/a'], cliPaths: ['/b'] }, + { provider: 'gemini', paths: [] }, + ]) + + const result = readHistorySourcesConfig() + expect(result[0]).toEqual({ provider: 'copilot', paths: ['/a'], cliPaths: ['/b'] }) + }) + + it('creates parent directories as needed', () => { + const nested = join(tempDir, 'nested', 'deeply', 'history-sources.json') + process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = nested + + writeHistorySourcesConfig([{ provider: 'opencode', paths: ['/foo'] }]) + + const result = readHistorySourcesConfig() + expect(result[0]!.paths).toEqual(['/foo']) + }) + }) + + describe('updateHistorySource', () => { + it('updates paths for an existing provider', () => { + writeHistorySourcesConfig([{ provider: 'gemini', paths: ['/old'] }]) + + const result = updateHistorySource('gemini', { paths: ['/new1', '/new2'] }) + + expect(result.find((s) => s.provider === 'gemini')?.paths).toEqual(['/new1', '/new2']) + }) + + it('updates cliPaths for copilot without touching paths', () => { + writeHistorySourcesConfig([ + { provider: 'copilot', paths: ['/vscode'], cliPaths: ['/cli-old'] }, + ]) + + const result = updateHistorySource('copilot', { cliPaths: ['/cli-new'] }) + + const copilot = result.find((s) => s.provider === 'copilot') + expect(copilot?.paths).toEqual(['/vscode']) + expect(copilot?.cliPaths).toEqual(['/cli-new']) + }) + + it('adds provider entry when not present', () => { + writeHistorySourcesConfig([{ provider: 'gemini', paths: [] }]) + + const result = updateHistorySource('opencode', { paths: ['/foo'] }) + + expect(result.find((s) => s.provider === 'opencode')?.paths).toEqual(['/foo']) + }) + + it('persists changes to disk', () => { + writeHistorySourcesConfig([{ provider: 'gemini', paths: [] }]) + + updateHistorySource('gemini', { paths: ['/persisted'] }) + const result = readHistorySourcesConfig() + + expect(result.find((s) => s.provider === 'gemini')?.paths).toEqual(['/persisted']) + }) + }) + + describe('getHistoryHintsForProvider', () => { + it('returns empty arrays for unknown provider', () => { + readHistorySourcesConfig() // init defaults + + const result = getHistoryHintsForProvider('gemini') + expect(result).toEqual({ historyPathHints: [], cliHistoryPathHints: [] }) + }) + + it('returns configured paths and cliPaths for copilot', () => { + writeHistorySourcesConfig([{ provider: 'copilot', paths: ['/a', '/b'], cliPaths: ['/c'] }]) + + const result = getHistoryHintsForProvider('copilot') + expect(result).toEqual({ historyPathHints: ['/a', '/b'], cliHistoryPathHints: ['/c'] }) + }) + + it('returns paths and empty cliPaths for gemini', () => { + writeHistorySourcesConfig([{ provider: 'gemini', paths: ['/g'] }]) + + const result = getHistoryHintsForProvider('gemini') + expect(result).toEqual({ historyPathHints: ['/g'], cliHistoryPathHints: [] }) + }) + }) +}) diff --git a/backend/src/history/sources-config.ts b/backend/src/history/sources-config.ts new file mode 100644 index 0000000..8e7d986 --- /dev/null +++ b/backend/src/history/sources-config.ts @@ -0,0 +1,133 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +import { resolveConfigPath } from '../storage.js' + +export type HistoryProvider = 'gemini' | 'copilot' | 'opencode' + +export interface HistorySourceRecord { + provider: HistoryProvider + /** VS Code workspace storage roots (or generic search roots for non-Copilot providers). */ + paths: string[] + /** CLI session-state directory paths. Only meaningful for the `copilot` provider. */ + cliPaths?: string[] +} + +interface HistorySourcesConfigFile { + sources?: HistorySourceRecord[] +} + +function getConfigPath(): string { + return resolveConfigPath('history-sources.json', 'ACP_HISTORY_SOURCES_CONFIG_PATH') +} + +const DEFAULT_SOURCES: HistorySourceRecord[] = [ + { provider: 'copilot', paths: [], cliPaths: [] }, + { provider: 'gemini', paths: [] }, + { provider: 'opencode', paths: [] }, +] + +export function readHistorySourcesConfig(): HistorySourceRecord[] { + ensureHistorySourcesConfigExists() + const file = readHistorySourcesConfigFile() + const configured = file.sources + + if (!configured || configured.length === 0) { + return DEFAULT_SOURCES + } + + return configured.map(normalizeHistorySourceRecord) +} + +export function writeHistorySourcesConfig(sources: HistorySourceRecord[]): void { + const configPath = getConfigPath() + const parentDir = dirname(configPath) + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }) + } + + writeFileSync(configPath, JSON.stringify({ sources }, null, 2)) +} + +export function updateHistorySource( + provider: HistoryProvider, + patch: { paths?: string[]; cliPaths?: string[] } +): HistorySourceRecord[] { + const config = readHistorySourcesConfig() + const index = config.findIndex((s) => s.provider === provider) + + if (index === -1) { + config.push( + normalizeHistorySourceRecord({ + provider, + paths: patch.paths ?? [], + cliPaths: patch.cliPaths, + }) + ) + } else { + const current = config[index]! + config[index] = normalizeHistorySourceRecord({ + provider, + paths: Array.isArray(patch.paths) ? patch.paths : current.paths, + cliPaths: patch.cliPaths !== undefined ? patch.cliPaths : current.cliPaths, + }) + } + + writeHistorySourcesConfig(config) + return config +} + +/** + * Returns the `BackendHistoryConfig`-compatible shape for a given provider ID so + * that `history/index.ts` functions can be called without change. + */ +export function getHistoryHintsForProvider(provider: HistoryProvider): { + historyPathHints: string[] + cliHistoryPathHints: string[] +} { + const config = readHistorySourcesConfig() + const record = config.find((s) => s.provider === provider) + return { + historyPathHints: record?.paths ?? [], + cliHistoryPathHints: record?.cliPaths ?? [], + } +} + +function readHistorySourcesConfigFile(): HistorySourcesConfigFile { + const configPath = getConfigPath() + if (!existsSync(configPath)) { + return {} + } + + try { + return JSON.parse(readFileSync(configPath, 'utf8')) as HistorySourcesConfigFile + } catch { + return {} + } +} + +function ensureHistorySourcesConfigExists(): void { + if (existsSync(getConfigPath())) { + return + } + + writeHistorySourcesConfig(DEFAULT_SOURCES) +} + +function normalizeHistorySourceRecord(record: HistorySourceRecord): HistorySourceRecord { + const normalized: HistorySourceRecord = { + provider: record.provider, + paths: Array.isArray(record.paths) + ? record.paths.filter((p): p is string => typeof p === 'string') + : [], + } + + // Only include cliPaths for copilot; include it (even empty) when present so + // the key is persisted. + if (record.provider === 'copilot' || record.cliPaths !== undefined) { + normalized.cliPaths = Array.isArray(record.cliPaths) + ? record.cliPaths.filter((p): p is string => typeof p === 'string') + : [] + } + + return normalized +} diff --git a/backend/src/routes/agents.test.ts b/backend/src/routes/agents.test.ts index dd2828e..9eb8238 100644 --- a/backend/src/routes/agents.test.ts +++ b/backend/src/routes/agents.test.ts @@ -1,23 +1,33 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import { Hono } from 'hono' import { agentsRoutes } from './agents.js' import type { AgentRegistry } from '../agents/registry.js' +function makeTempDir(): string { + const dir = join(tmpdir(), `acp-agents-routes-test-${Date.now()}-${Math.random()}`) + mkdirSync(dir, { recursive: true }) + return dir +} + function createRegistryStub(): AgentRegistry { return { listAgents: vi.fn(() => []), listBackends: vi.fn(() => [ { - id: 'copilot-vscode-host', - name: 'GitHub Copilot VS Code (Host)', + id: 'copilot', + name: 'GitHub Copilot', status: 'active', command: null, detectedCommand: null, args: [], defaultArgs: [], - historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'], enabled: true, usesCustomCommand: false, + canResume: false, + canLoad: false, endpointSupport: { source: 'connection', implemented: ['session/new'], @@ -40,9 +50,10 @@ function createRegistryStub(): AgentRegistry { detectedCommand: 'custom-wrapper', args: ['--acp'], defaultArgs: ['--acp'], - historyPathHints: [], enabled: true, usesCustomCommand: true, + canResume: false, + canLoad: false, endpointSupport: { source: 'unknown', implemented: [], @@ -57,17 +68,17 @@ function createRegistryStub(): AgentRegistry { lastTestResult: null, })), updateBackend: vi.fn(() => ({ - id: 'copilot-vscode-host', - name: 'GitHub Copilot VS Code (Host)', + id: 'copilot', + name: 'GitHub Copilot', status: 'disabled', command: null, detectedCommand: null, args: [], defaultArgs: [], - historyPathHints: ['/tmp/copilot-hints'], - cliHistoryPathHints: ['/tmp/cli-hints'], enabled: false, usesCustomCommand: true, + canResume: false, + canLoad: false, endpointSupport: { source: 'unknown', implemented: [], @@ -90,6 +101,24 @@ function createRegistryStub(): AgentRegistry { } describe('agents routes', () => { + let tempDir: string + let origEnv: string | undefined + + beforeEach(() => { + tempDir = makeTempDir() + origEnv = process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] + process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = join(tempDir, 'history-sources.json') + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + if (origEnv === undefined) { + delete process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] + } else { + process.env['ACP_HISTORY_SOURCES_CONFIG_PATH'] = origEnv + } + }) + it('returns backend settings', async () => { const registry = createRegistryStub() const app = new Hono().route('/api', agentsRoutes(registry)) @@ -98,33 +127,26 @@ describe('agents routes', () => { expect(res.status).toBe(200) const body = (await res.json()) as Array<{ id: string; enabled: boolean }> - expect(body[0]).toMatchObject({ id: 'copilot-vscode-host', enabled: true }) + expect(body[0]).toMatchObject({ id: 'copilot', enabled: true }) }) it('updates a backend config', async () => { const registry = createRegistryStub() const app = new Hono().route('/api', agentsRoutes(registry)) - const res = await app.request('/api/backends/copilot-vscode-host', { + const res = await app.request('/api/backends/copilot', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: false, command: 'copilot-wrapper', args: ['--stdio'], - historyPathHints: ['/tmp/copilot-hints'], - cliHistoryPathHints: ['/tmp/cli-hints'], }), }) expect(res.status).toBe(200) const body = (await res.json()) as { enabled: boolean; command: string } - expect(body).toMatchObject({ - enabled: false, - command: null, - historyPathHints: ['/tmp/copilot-hints'], - cliHistoryPathHints: ['/tmp/cli-hints'], - }) + expect(body).toMatchObject({ enabled: false, command: null }) }) it('creates a custom backend config', async () => { @@ -141,4 +163,65 @@ describe('agents routes', () => { const body = (await res.json()) as { id: string; command: string } expect(body).toMatchObject({ id: 'custom-wrapper', command: 'custom-wrapper' }) }) + + describe('history-sources routes', () => { + it('GET /history-sources returns default sources', async () => { + const registry = createRegistryStub() + const app = new Hono().route('/api', agentsRoutes(registry)) + + const res = await app.request('/api/history-sources') + expect(res.status).toBe(200) + + const body = (await res.json()) as Array<{ provider: string }> + expect(body.map((s) => s.provider)).toEqual( + expect.arrayContaining(['copilot', 'gemini', 'opencode']) + ) + }) + + it('PATCH /history-sources/copilot updates copilot paths', async () => { + const registry = createRegistryStub() + const app = new Hono().route('/api', agentsRoutes(registry)) + + const res = await app.request('/api/history-sources/copilot', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: ['/a/b'], cliPaths: ['/c/d'] }), + }) + + expect(res.status).toBe(200) + const body = (await res.json()) as { provider: string; paths: string[]; cliPaths: string[] } + expect(body.provider).toBe('copilot') + expect(body.paths).toEqual(['/a/b']) + expect(body.cliPaths).toEqual(['/c/d']) + }) + + it('PATCH /history-sources/gemini updates gemini paths', async () => { + const registry = createRegistryStub() + const app = new Hono().route('/api', agentsRoutes(registry)) + + const res = await app.request('/api/history-sources/gemini', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: ['/gemini/path'] }), + }) + + expect(res.status).toBe(200) + const body = (await res.json()) as { provider: string; paths: string[] } + expect(body.provider).toBe('gemini') + expect(body.paths).toEqual(['/gemini/path']) + }) + + it('PATCH /history-sources/:provider returns 404 for unknown provider', async () => { + const registry = createRegistryStub() + const app = new Hono().route('/api', agentsRoutes(registry)) + + const res = await app.request('/api/history-sources/unknown-provider', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: [] }), + }) + + expect(res.status).toBe(404) + }) + }) }) diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts index 09ed4f7..18fb80a 100644 --- a/backend/src/routes/agents.ts +++ b/backend/src/routes/agents.ts @@ -1,5 +1,12 @@ import { Hono } from 'hono' import type { AgentRegistry } from '../agents/registry.js' +import { + readHistorySourcesConfig, + updateHistorySource, + type HistoryProvider, +} from '../history/sources-config.js' + +const VALID_PROVIDERS = new Set(['gemini', 'copilot', 'opencode']) export function agentsRoutes(registry: AgentRegistry): Hono { const app = new Hono() @@ -38,8 +45,6 @@ export function agentsRoutes(registry: AgentRegistry): Hono { command?: string | null args?: string[] name?: string - historyPathHints?: string[] - cliHistoryPathHints?: string[] }>() try { @@ -55,5 +60,37 @@ export function agentsRoutes(registry: AgentRegistry): Hono { } }) + // --- History Sources --- + + app.get('/history-sources', (c) => { + const sources = readHistorySourcesConfig() + return c.json(sources) + }) + + app.patch('/history-sources/:provider', async (c) => { + const provider = c.req.param('provider') + + if (!VALID_PROVIDERS.has(provider)) { + return c.json({ error: `Unknown provider: ${provider}` }, 404) + } + + const body = await c.req.json<{ + paths?: string[] + cliPaths?: string[] + }>() + + try { + const updated = updateHistorySource(provider as HistoryProvider, { + paths: body.paths, + cliPaths: body.cliPaths, + }) + const record = updated.find((s) => s.provider === provider) + return c.json(record) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return c.json({ error: message }, 400) + } + }) + return app } diff --git a/frontend/src/hooks/useBackendSettings.ts b/frontend/src/hooks/useBackendSettings.ts index 7c9c0b3..c1a2f72 100644 --- a/frontend/src/hooks/useBackendSettings.ts +++ b/frontend/src/hooks/useBackendSettings.ts @@ -65,9 +65,6 @@ export interface BackendSummary { detectedCommand: string | null args: string[] defaultArgs: string[] - historyPathHints: string[] - /** CLI session-state directory hints. Only used by the `copilot` backend. */ - cliHistoryPathHints: string[] enabled: boolean usesCustomCommand: boolean endpointSupport: BackendEndpointSupport @@ -79,11 +76,20 @@ export interface BackendSummary { } | null } +export type HistoryProvider = 'gemini' | 'copilot' | 'opencode' + +export interface HistorySourceConfig { + provider: HistoryProvider + /** VS Code workspace storage roots (or generic search roots for non-Copilot providers). */ + paths: string[] + /** CLI session-state directory paths. Only meaningful for `copilot`. */ + cliPaths?: string[] +} + export function useBackendSettings() { const [backends, setBackends] = useState([]) const [loading, setLoading] = useState(true) const [savingId, setSavingId] = useState(null) - const [testingId, setTestingId] = useState(null) const [errorMessage, setErrorMessage] = useState(null) const loadBackends = useCallback(async () => { @@ -117,8 +123,6 @@ export function useBackendSettings() { command?: string | null args?: string[] name?: string - historyPathHints?: string[] - cliHistoryPathHints?: string[] } ) => { setSavingId(backendId) @@ -150,32 +154,6 @@ export function useBackendSettings() { [] ) - const testBackend = useCallback(async (backendId: string) => { - setTestingId(backendId) - setErrorMessage(null) - - try { - const response = await fetch(`/api/backends/${encodeURIComponent(backendId)}/test`, { - method: 'POST', - }) - - if (!response.ok) { - throw new Error(`Backend test failed with status ${response.status}`) - } - - const updated = (await response.json()) as BackendSummary - setBackends((current) => - current.map((backend) => (backend.id === backendId ? updated : backend)) - ) - } catch (error) { - console.error('[useBackendSettings] test failed:', error) - setErrorMessage('Unable to test this backend right now.') - throw error - } finally { - setTestingId(null) - } - }, []) - const addBackend = useCallback( async (input: { name: string; command: string; args?: string[] }) => { setErrorMessage(null) @@ -209,7 +187,72 @@ export function useBackendSettings() { loading, saveBackend, savingId, - testBackend, - testingId, + } +} + +export function useHistorySources() { + const [sources, setSources] = useState([]) + const [loading, setLoading] = useState(true) + const [savingProvider, setSavingProvider] = useState(null) + const [errorMessage, setErrorMessage] = useState(null) + + const loadSources = useCallback(async () => { + setLoading(true) + setErrorMessage(null) + + try { + const response = await fetch('/api/history-sources') + if (!response.ok) { + throw new Error(`History sources failed with status ${response.status}`) + } + + setSources((await response.json()) as HistorySourceConfig[]) + } catch (error) { + console.error('[useHistorySources] load failed:', error) + setErrorMessage('Unable to load history sources right now.') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + void loadSources() + }, [loadSources]) + + const saveSource = useCallback( + async (provider: HistoryProvider, patch: { paths?: string[]; cliPaths?: string[] }) => { + setSavingProvider(provider) + setErrorMessage(null) + + try { + const response = await fetch(`/api/history-sources/${encodeURIComponent(provider)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }) + + if (!response.ok) { + throw new Error(`History source save failed with status ${response.status}`) + } + + const updated = (await response.json()) as HistorySourceConfig + setSources((current) => current.map((s) => (s.provider === provider ? updated : s))) + } catch (error) { + console.error('[useHistorySources] save failed:', error) + setErrorMessage('Unable to save history source settings right now.') + throw error + } finally { + setSavingProvider(null) + } + }, + [] + ) + + return { + sources, + loading, + saveSource, + savingProvider, + errorMessage, } } diff --git a/frontend/src/router.test.tsx b/frontend/src/router.test.tsx index 77d8dae..f3e1d8f 100644 --- a/frontend/src/router.test.tsx +++ b/frontend/src/router.test.tsx @@ -25,8 +25,6 @@ function mockFetch() { detectedCommand: null, args: [], defaultArgs: [], - historyPathHints: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'], - cliHistoryPathHints: [], enabled: true, usesCustomCommand: false, endpointSupport: { @@ -79,39 +77,19 @@ function mockFetch() { } as Response) } - if (url === '/api/backends/copilot-vscode-host/test' && opts?.method === 'POST') { + if (url === '/api/history-sources') { return Promise.resolve({ ok: true, json: () => - Promise.resolve({ - id: 'copilot-vscode-host', - name: 'GitHub Copilot VS Code (Host)', - status: 'active', - command: null, - detectedCommand: null, - args: [], - defaultArgs: [], - historyPathHints: [], - cliHistoryPathHints: [], - enabled: true, - usesCustomCommand: false, - endpointSupport: { - source: 'connection', - implemented: ['session/new', 'session/list'], - unknown: [], - }, - historySupport: { - source: 'derived', - supported: ['text', 'markdown'], - discoveredSources: [], - discoverySummary: [], - }, - lastTestResult: { - ok: true, - message: 'ACP initialize succeeded.', - testedAt: '2026-03-18T18:00:00.000Z', + Promise.resolve([ + { + provider: 'copilot', + paths: ['/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage'], + cliPaths: [], }, - }), + { provider: 'gemini', paths: [] }, + { provider: 'opencode', paths: [] }, + ]), } as Response) } @@ -128,8 +106,6 @@ function mockFetch() { detectedCommand: 'custom-wrapper', args: ['--acp'], defaultArgs: ['--acp'], - historyPathHints: [], - cliHistoryPathHints: [], enabled: true, usesCustomCommand: true, endpointSupport: { @@ -292,15 +268,17 @@ describe('app router', () => { await waitFor(() => expect(screen.getByText('ACP Backends')).toBeDefined()) expect(screen.getByDisplayValue('GitHub Copilot VS Code (Host)')).toBeDefined() expect(screen.getByText('Add Backend')).toBeDefined() - expect( - screen.getByDisplayValue('/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage') - ).toBeDefined() - expect(screen.getByText('History Sources')).toBeDefined() + expect(screen.getAllByText('History Sources').length).toBeGreaterThan(0) expect(screen.getByText('vscode_workspace_db')).toBeDefined() expect(screen.getByText('vscode_chat_sessions')).toBeDefined() expect(screen.getByText('42 sessions')).toBeDefined() expect(screen.getByRole('link', { name: 'Back To Chat' })).toBeDefined() - expect(screen.getByRole('button', { name: 'Test' })).toBeDefined() + // History Sources section is present (separate from backend cards) + await waitFor(() => + expect( + screen.getByDisplayValue('/mnt/c/Users/vries/AppData/Roaming/Code/User/workspaceStorage') + ).toBeDefined() + ) }) it('normalizes blank chat search params to undefined', async () => { diff --git a/frontend/src/routes/settings.tsx b/frontend/src/routes/settings.tsx index a4b8038..e8e3c1d 100644 --- a/frontend/src/routes/settings.tsx +++ b/frontend/src/routes/settings.tsx @@ -2,52 +2,37 @@ import { Link } from '@tanstack/react-router' import { useState } from 'react' import { useBackendSettings, + useHistorySources, type BackendSummary, + type HistoryProvider, + type HistorySourceConfig, type HistorySourceDescriptor, } from '../hooks/useBackendSettings.js' -// Map each path-hint textarea to the source kinds that feed it so we can -// derive a meaningful placeholder from auto-discovered paths. -const VSCODE_ROOT_KINDS: HistorySourceDescriptor['kind'][] = [ - 'vscode_workspace_db', - 'vscode_chat_sessions', - 'vscode_chat_editing_sessions', - 'vscode_extension_resources', -] -const CLI_DIR_KINDS: HistorySourceDescriptor['kind'][] = ['cli_session_dir', 'cli_history_dir'] - -/** Extract unique parent directory paths for a set of source kinds. */ -function autoDiscoveredRoots( - sources: HistorySourceDescriptor[], - kinds: HistorySourceDescriptor['kind'][] -): string[] { - const kindSet = new Set(kinds) - const paths = sources - .filter((s) => kindSet.has(s.kind) && s.discoveredBy === 'auto') - .map((s) => { - // For workspace-db style sources the configured root is the *parent* of - // the discovered path (e.g. /foo/workspaceStorage/abc → /foo/workspaceStorage). - const parts = s.path.split('/') - return parts.slice(0, -1).join('/') || s.path - }) - return [...new Set(paths)] -} - export function SettingsPage() { const { backends, - errorMessage, - loading, + errorMessage: backendError, + loading: backendLoading, saveBackend, savingId, addBackend, - testBackend, - testingId, } = useBackendSettings() + + const { + sources, + loading: sourcesLoading, + saveSource, + savingProvider, + errorMessage: sourcesError, + } = useHistorySources() + const [newName, setNewName] = useState('') const [newCommand, setNewCommand] = useState('') const [newArgs, setNewArgs] = useState('') + const errorMessage = backendError ?? sourcesError + const handleAddBackend = async () => { if (!newName.trim() || !newCommand.trim()) { return @@ -110,7 +95,7 @@ export function SettingsPage() { instead of guessing.

- {loading ? ( + {backendLoading ? (
Loading backend settings...
@@ -121,9 +106,7 @@ export function SettingsPage() { key={backend.id} backend={backend} busy={savingId === backend.id} - testing={testingId === backend.id} onSave={saveBackend} - onTest={testBackend} /> ))} @@ -179,6 +162,34 @@ export function SettingsPage() { +
+

+ History Sources +

+

+ Configure where each AI provider stores its conversation history. These paths are used + to discover sessions for import. History path hints are stored separately from backend + connection settings. +

+ + {sourcesLoading ? ( +
+ Loading history sources... +
+ ) : ( +
+ {sources.map((source) => ( + + ))} +
+ )} +
+

MCP Servers @@ -200,7 +211,6 @@ export const BackendSettingsPage = SettingsPage interface BackendCardProps { backend: BackendSummary busy: boolean - testing: boolean onSave: ( backendId: string, patch: { @@ -208,51 +218,26 @@ interface BackendCardProps { command?: string | null args?: string[] name?: string - historyPathHints?: string[] - cliHistoryPathHints?: string[] } ) => Promise - onTest: (backendId: string) => Promise } -function BackendCard({ backend, busy, testing, onSave, onTest }: BackendCardProps) { +function BackendCard({ backend, busy, onSave }: BackendCardProps) { const [enabled, setEnabled] = useState(backend.enabled) const [name, setName] = useState(backend.name) const [command, setCommand] = useState(backend.command ?? '') const [args, setArgs] = useState(backend.args.join(' ')) - const [historyPathHints, setHistoryPathHints] = useState(backend.historyPathHints.join('\n')) - const [cliHistoryPathHints, setCliHistoryPathHints] = useState( - backend.cliHistoryPathHints.join('\n') - ) const detectedLabel = backend.detectedCommand ? `Detected: ${backend.detectedCommand}` : 'Not detected' - const isCopilot = backend.id === 'copilot' - - // Build placeholder text from auto-discovered paths so the textarea doesn't - // look empty when the backend has already found the right directories. - const vscodeAutoRoots = isCopilot - ? autoDiscoveredRoots(backend.historySupport.discoveredSources, VSCODE_ROOT_KINDS) - : [] - const cliAutoRoots = isCopilot - ? autoDiscoveredRoots(backend.historySupport.discoveredSources, CLI_DIR_KINDS) - : [] - - const vscodeRootsPlaceholder = - vscodeAutoRoots.length > 0 ? vscodeAutoRoots.join('\n') : 'One path per line' - const cliRootsPlaceholder = - cliAutoRoots.length > 0 ? cliAutoRoots.join('\n') : 'One path per line' - const handleSave = async () => { await onSave(backend.id, { name, enabled, command: command.trim() || null, args: parseArgs(args), - historyPathHints: parseLines(historyPathHints), - cliHistoryPathHints: parseLines(cliHistoryPathHints), }) } @@ -306,22 +291,80 @@ function BackendCard({ backend, busy, testing, onSave, onTest }: BackendCardProp className="mt-2 w-full rounded-lg border border-white/10 bg-slate-950 px-3 py-2 text-sm text-slate-100 outline-none focus:border-teal-500" /> + + +

+ +
+
+

+ {backend.endpointSupport.source === 'connection' + ? 'Capabilities come from the last successful ACP initialize response.' + : 'Capabilities are unknown until this backend completes a live ACP handshake.'} +

+ +
+
+ ) +} + +interface HistorySourceCardProps { + source: HistorySourceConfig + busy: boolean + onSave: ( + provider: HistoryProvider, + patch: { paths?: string[]; cliPaths?: string[] } + ) => Promise +} + +function HistorySourceCard({ source, busy, onSave }: HistorySourceCardProps) { + const [paths, setPaths] = useState(source.paths.join('\n')) + const [cliPaths, setCliPaths] = useState((source.cliPaths ?? []).join('\n')) + + const isCopilot = source.provider === 'copilot' + + const handleSave = async () => { + await onSave(source.provider, { + paths: parseLines(paths), + ...(isCopilot ? { cliPaths: parseLines(cliPaths) } : {}), + }) + } + + const providerLabel: Record = { + copilot: 'GitHub Copilot', + gemini: 'Gemini CLI', + opencode: 'OpenCode', + } + + return ( +
+

{providerLabel[source.provider]}

+

{source.provider}

+ +