Skip to content

Commit a233e04

Browse files
committed
Add Switchboard deep link import preview
1 parent e8f6c61 commit a233e04

9 files changed

Lines changed: 364 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Install the packaged plugin into a local HaloForge workspace with the `hf` CLI:
8383

8484
```bash
8585
cd /path/to/HaloForge
86-
npm run hf -- plugin install local /path/to/hf-plugin-switchboard/dist/package/dev.haloforge.switchboard-0.1.6.hfpkg --json
86+
npm run hf -- plugin install local /path/to/hf-plugin-switchboard/dist/package/dev.haloforge.switchboard-0.1.7.hfpkg --json
8787
npm run hf -- plugin list --json
8888
```
8989

backend/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "hf-plugin-switchboard"
3-
version = "0.1.6"
3+
version = "0.1.7"
44
edition = "2021"
55
description = "HaloForge Switchboard plugin backend"
66
license = "MIT"

frontend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@hf-plugin-switchboard/frontend",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"private": true,
55
"type": "module",
66
"scripts": {

frontend/src/SwitchboardPanel.tsx

Lines changed: 186 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
1-
import { clearPendingPluginDeepLink, usePluginDeepLink, usePluginSettings } from "@haloforge/plugin-sdk";
2-
import { Bot, Braces, LayoutDashboard, RefreshCcw, Shield, TerminalSquare } from "lucide-react";
1+
import { clearPendingPluginDeepLink, usePluginDeepLink, usePluginSettings, type PluginDeepLink } from "@haloforge/plugin-sdk";
2+
import { Bot, Braces, CheckCircle2, KeyRound, LayoutDashboard, RefreshCcw, Shield, TerminalSquare, X } from "lucide-react";
33
import { useCallback, useEffect, useState } from "react";
44
import { BackupPanel } from "./components/BackupPanel";
55
import { McpPanel } from "./components/McpPanel";
66
import { ProviderPanel } from "./components/ProviderPanel";
77
import { TargetCard } from "./components/TargetCard";
88
import { DEFAULT_CODEX_PROVIDER_ID, DEFAULT_MCP_SPEC, defaultProviderForm } from "./defaults";
9-
import { useSwitchboardT } from "./i18n";
9+
import { useSwitchboardT, type SwitchboardTranslationKey } from "./i18n";
1010
import { useSwitchboard } from "./hooks/useSwitchboard";
1111
import type { McpAppSelection, PluginSettings, ProviderForm, SwitchboardImportPatch } from "./types";
1212

1313
type SwitchboardTab = "overview" | "claude" | "codex" | "mcp" | "backups";
1414

15+
interface PendingImport {
16+
patch: SwitchboardImportPatch;
17+
providerPatch: SwitchboardImportPatch["provider"] | null;
18+
}
19+
1520
function buildProviderForm(settings: PluginSettings, target: ProviderForm["target"]): ProviderForm {
1621
return {
1722
...defaultProviderForm(settings),
@@ -31,6 +36,7 @@ export function SwitchboardPanel() {
3136
codex: true,
3237
});
3338
const [mcpSpec, setMcpSpec] = useState(DEFAULT_MCP_SPEC);
39+
const [pendingImport, setPendingImport] = useState<PendingImport | null>(null);
3440
const {
3541
status,
3642
busy,
@@ -75,7 +81,7 @@ export function SwitchboardPanel() {
7581
setMessage(t("switchboard.message.importReady"));
7682
}, [setMessage, settings.defaultTarget, t]);
7783

78-
usePluginDeepLink(useCallback((link) => {
84+
usePluginDeepLink(useCallback((link: PluginDeepLink) => {
7985
if (link.route !== "/v1/import" && link.route !== "/import") {
8086
return;
8187
}
@@ -84,9 +90,25 @@ export function SwitchboardPanel() {
8490
setMessage(t("switchboard.message.importInvalid"));
8591
return;
8692
}
87-
applyImportPatch(patch);
93+
const providerPatch = normalizeProviderPatch(patch.provider);
94+
const targetTab = resolveImportTab(patch, providerPatch, settings.defaultTarget ?? "both");
95+
setActiveTab(targetTab);
96+
setPendingImport({ patch, providerPatch });
97+
setMessage(null);
8898
clearPendingPluginDeepLink();
89-
}, [applyImportPatch, setMessage, t]));
99+
}, [setMessage, settings.defaultTarget, t]));
100+
101+
const confirmPendingImport = useCallback(() => {
102+
if (!pendingImport) {
103+
return;
104+
}
105+
applyImportPatch(pendingImport.patch);
106+
setPendingImport(null);
107+
}, [applyImportPatch, pendingImport]);
108+
109+
const cancelPendingImport = useCallback(() => {
110+
setPendingImport(null);
111+
}, []);
90112

91113
useEffect(() => {
92114
setClaudeForm((current) => ({
@@ -251,10 +273,167 @@ export function SwitchboardPanel() {
251273
<BackupPanel backups={status?.backups ?? []} busy={busy} onRestore={(id) => void restoreBackup(id)} t={t} />
252274
</section>
253275
)}
276+
277+
{pendingImport && (
278+
<ImportPreviewDialog
279+
pendingImport={pendingImport}
280+
defaultTarget={settings.defaultTarget ?? "both"}
281+
onCancel={cancelPendingImport}
282+
onConfirm={confirmPendingImport}
283+
t={t}
284+
/>
285+
)}
254286
</main>
255287
);
256288
}
257289

290+
interface ImportPreviewDialogProps {
291+
pendingImport: PendingImport;
292+
defaultTarget: ProviderForm["target"];
293+
onCancel: () => void;
294+
onConfirm: () => void;
295+
t: (key: SwitchboardTranslationKey, vars?: Record<string, string | number>) => string;
296+
}
297+
298+
function ImportPreviewDialog({
299+
pendingImport,
300+
defaultTarget,
301+
onCancel,
302+
onConfirm,
303+
t,
304+
}: ImportPreviewDialogProps) {
305+
const { patch, providerPatch } = pendingImport;
306+
const target = providerPatch?.target ?? defaultTarget;
307+
const targetLabel = target === "codex"
308+
? t("switchboard.tab.codex")
309+
: target === "claude"
310+
? t("switchboard.tab.claude")
311+
: t("switchboard.import.targetBoth");
312+
const providerRows = buildImportProviderRows(providerPatch, t);
313+
const hasMcp = Boolean(patch.mcp);
314+
const mcpTargets = patch.mcp?.apps
315+
? [
316+
patch.mcp.apps.claude ? t("switchboard.tab.claude") : null,
317+
patch.mcp.apps.codex ? t("switchboard.tab.codex") : null,
318+
].filter(Boolean).join(", ")
319+
: t("switchboard.import.targetBoth");
320+
321+
return (
322+
<div className="sb-modal-backdrop" role="presentation">
323+
<section
324+
aria-labelledby="switchboard-import-title"
325+
aria-modal="true"
326+
className="sb-import-dialog"
327+
role="dialog"
328+
>
329+
<div className="sb-import-head">
330+
<div className="sb-import-title-row">
331+
<span className="sb-import-icon" aria-hidden="true">
332+
<KeyRound size={18} />
333+
</span>
334+
<div>
335+
<h2 id="switchboard-import-title">{t("switchboard.import.title")}</h2>
336+
<p>{t("switchboard.import.subtitle", { target: targetLabel })}</p>
337+
</div>
338+
</div>
339+
<button className="sb-mini-icon-button" type="button" onClick={onCancel} title={t("switchboard.import.cancel")}>
340+
<X size={14} />
341+
</button>
342+
</div>
343+
344+
{providerPatch && (
345+
<div className="sb-import-section">
346+
<div className="sb-import-section-head">
347+
<strong>{t("switchboard.import.providerTitle")}</strong>
348+
<span className="sb-status-chip sb-status-chip-on">{targetLabel}</span>
349+
</div>
350+
<div className="sb-import-grid">
351+
{providerRows.map((row) => (
352+
<div className="sb-import-row" key={row.label}>
353+
<span>{row.label}</span>
354+
<code>{row.value}</code>
355+
</div>
356+
))}
357+
</div>
358+
</div>
359+
)}
360+
361+
{hasMcp && (
362+
<div className="sb-import-section">
363+
<div className="sb-import-section-head">
364+
<strong>{t("switchboard.import.mcpTitle")}</strong>
365+
<span className="sb-status-chip">{mcpTargets}</span>
366+
</div>
367+
<div className="sb-import-grid">
368+
{patch.mcp?.id && (
369+
<div className="sb-import-row">
370+
<span>{t("switchboard.mcp.id")}</span>
371+
<code>{patch.mcp.id}</code>
372+
</div>
373+
)}
374+
{patch.mcp?.specText && (
375+
<div className="sb-import-row sb-import-row-wide">
376+
<span>{t("switchboard.mcp.spec")}</span>
377+
<code>{compactPreview(patch.mcp.specText)}</code>
378+
</div>
379+
)}
380+
</div>
381+
</div>
382+
)}
383+
384+
<p className="sb-import-note">{t("switchboard.import.note")}</p>
385+
386+
<div className="sb-import-actions">
387+
<button className="sb-secondary-button" type="button" onClick={onCancel}>
388+
{t("switchboard.import.cancel")}
389+
</button>
390+
<button className="sb-primary-button" type="button" onClick={onConfirm}>
391+
<CheckCircle2 size={16} />
392+
{t("switchboard.import.confirm")}
393+
</button>
394+
</div>
395+
</section>
396+
</div>
397+
);
398+
}
399+
400+
function buildImportProviderRows(
401+
providerPatch: SwitchboardImportPatch["provider"] | null,
402+
t: (key: SwitchboardTranslationKey, vars?: Record<string, string | number>) => string,
403+
) {
404+
if (!providerPatch) {
405+
return [];
406+
}
407+
return [
408+
{ label: t("switchboard.provider.name"), value: providerPatch.name },
409+
{ label: t("switchboard.provider.baseUrl"), value: providerPatch.baseUrl },
410+
{ label: t("switchboard.provider.apiKey"), value: maskSecret(providerPatch.apiKey) },
411+
{ label: t("switchboard.provider.model"), value: providerPatch.model },
412+
{ label: t("switchboard.provider.modelsPath"), value: providerPatch.modelsPath },
413+
{ label: t("switchboard.provider.providerId"), value: providerPatch.providerId },
414+
{ label: t("switchboard.provider.reasoning"), value: providerPatch.reasoningEffort },
415+
].filter((row): row is { label: string; value: string } => typeof row.value === "string" && row.value.trim().length > 0);
416+
}
417+
418+
function maskSecret(value: string | undefined): string | undefined {
419+
if (!value) {
420+
return value;
421+
}
422+
const trimmed = value.trim();
423+
if (trimmed.length <= 8) {
424+
return "****";
425+
}
426+
return `${trimmed.slice(0, 4)}****${trimmed.slice(-4)}`;
427+
}
428+
429+
function compactPreview(value: string): string {
430+
return value.replace(/\s+/g, " ").trim();
431+
}
432+
433+
function parseProviderTarget(value: string | undefined): ProviderForm["target"] | undefined {
434+
return value === "claude" || value === "codex" || value === "both" ? value : undefined;
435+
}
436+
258437
function resolveImportTab(
259438
patch: SwitchboardImportPatch,
260439
providerPatch: SwitchboardImportPatch["provider"] | null,
@@ -307,7 +486,7 @@ function parseImportPatch(params: Record<string, string>): SwitchboardImportPatc
307486
}
308487

309488
const provider = normalizeProviderPatch({
310-
target: params.target,
489+
target: parseProviderTarget(params.target),
311490
name: params.name,
312491
baseUrl: params.baseUrl ?? params.base_url,
313492
apiKey: params.apiKey ?? params.api_key,

frontend/src/i18n.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ const en = {
1616
"switchboard.message.modelsEmpty": "No models were returned by that endpoint.",
1717
"switchboard.message.importReady": "Imported settings from deep link.",
1818
"switchboard.message.importInvalid": "The deep link import payload could not be parsed.",
19+
"switchboard.import.title": "Review imported configuration",
20+
"switchboard.import.subtitle": "A launch link wants to prepare {target} settings.",
21+
"switchboard.import.providerTitle": "Provider settings",
22+
"switchboard.import.mcpTitle": "MCP settings",
23+
"switchboard.import.targetBoth": "Claude and Codex",
24+
"switchboard.import.note": "This only fills the form and opens the matching tab. No local config file is written until you apply it.",
25+
"switchboard.import.cancel": "Cancel",
26+
"switchboard.import.confirm": "Import to form",
1927
"switchboard.tab.overview": "Overview",
2028
"switchboard.tab.claude": "Claude",
2129
"switchboard.tab.codex": "Codex",
@@ -98,6 +106,14 @@ const zh: Record<SwitchboardTranslationKey, string> = {
98106
"switchboard.message.modelsEmpty": "这个端点没有返回模型。",
99107
"switchboard.message.importReady": "已从启动链接导入配置。",
100108
"switchboard.message.importInvalid": "这个启动链接的导入内容无法解析。",
109+
"switchboard.import.title": "确认导入配置",
110+
"switchboard.import.subtitle": "启动链接想要准备 {target} 配置。",
111+
"switchboard.import.providerTitle": "Provider 配置",
112+
"switchboard.import.mcpTitle": "MCP 配置",
113+
"switchboard.import.targetBoth": "Claude 和 Codex",
114+
"switchboard.import.note": "这一步只会填入表单并打开对应页面,不会写入本机配置文件。需要你再点击写入按钮。",
115+
"switchboard.import.cancel": "取消",
116+
"switchboard.import.confirm": "导入到表单",
101117
"switchboard.tab.overview": "概览",
102118
"switchboard.tab.claude": "Claude",
103119
"switchboard.tab.codex": "Codex",

0 commit comments

Comments
 (0)