diff --git a/nuxt/components/content/FfCommand.vue b/nuxt/components/content/FfCommand.vue index 8c5b5a5241..141988e668 100644 --- a/nuxt/components/content/FfCommand.vue +++ b/nuxt/components/content/FfCommand.vue @@ -17,12 +17,79 @@ const props = withDefaults(defineProps<{ // the inline button leaves too little room for the address to stay on one // line, and where the button needs to match the other CTAs beside it. stacked?: boolean -}>(), { event: undefined, position: undefined, stacked: false }) + // Offers a field for a self-hosted platform address, which replaces the host in + // the command shown and copied. For addresses that are only the Cloud one by + // default: the docs say "substitute your own platform address" in prose, and a + // self-hosted reader otherwise copies an address that is not theirs. + hostSwap?: boolean +}>(), { event: undefined, position: undefined, stacked: false, hostSwap: false }) const capture = useCapture() const copied = ref(false) let resetTimer: ReturnType | undefined +// Shared by every FfCommand on the page rather than held per instance. /ai renders +// one per agent tab, so per-instance state would mean typing the address again on +// each tab, which is the friction this is here to remove. +// +// It holds what the reader typed, untouched, and the host is derived from it. +// Normalising the field itself on each keystroke does not work: the first slash of +// "https://" reads as the start of a path the moment it lands, so it is stripped +// again and the scheme can never complete. +const typed = useState('ff-command-host', () => '') +const editing = useState('ff-command-host-editing', () => false) + +const STORAGE_KEY = 'ff-command-host' + +// Takes what people actually paste: a bare domain, a full URL, a trailing path. +function normaliseHost (value: string) { + return value.trim().replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/[/?#].*$/, '') +} + +const host = computed(() => normaliseHost(typed.value)) + +const shownCommand = computed(() => { + if (!props.hostSwap || !host.value) return props.command + return props.command.replace(/^(https?:\/\/)[^/\s]+/i, `$1${host.value}`) +}) + +function remember () { + try { + if (host.value) localStorage.setItem(STORAGE_KEY, host.value) + else localStorage.removeItem(STORAGE_KEY) + } catch { + // Storage can be unavailable (private mode, blocked cookies). The field + // still works for this visit; it just will not be remembered. + } +} + +function onHostInput (event: Event) { + typed.value = (event.target as HTMLInputElement).value + remember() +} + +function useCloud () { + typed.value = '' + remember() + editing.value = false +} + +// Read after hydration, not during setup: the prerendered HTML carries the default +// address, so reading storage any earlier would make the server and client markup +// disagree. +onMounted(() => { + if (!props.hostSwap || typed.value) return + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + typed.value = stored + editing.value = true + } + } catch { + // As above. + } +}) + async function writeToClipboard (text: string) { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text) @@ -42,11 +109,18 @@ async function writeToClipboard (text: string) { async function copy () { try { - await writeToClipboard(props.command) + await writeToClipboard(shownCommand.value) } catch { return } - if (props.event) capture(props.event, props.position ? { position: props.position } : undefined) + if (props.event) { + const payload: Record = {} + if (props.position) payload.position = props.position + // Whether the reader copied their own address or ours, which is the one + // thing this control can tell us that the copy count cannot. + if (props.hostSwap) payload.self_hosted = !!host.value + capture(props.event, Object.keys(payload).length ? payload : undefined) + } copied.value = true clearTimeout(resetTimer) resetTimer = setTimeout(() => { copied.value = false }, 2000) @@ -56,22 +130,55 @@ onUnmounted(() => clearTimeout(resetTimer)) diff --git a/nuxt/pages/ai/index.vue b/nuxt/pages/ai/index.vue index abcbddd699..917524412c 100644 --- a/nuxt/pages/ai/index.vue +++ b/nuxt/pages/ai/index.vue @@ -52,6 +52,9 @@ useSchemaOrg([ ...FAQ.map(item => defineQuestion({ question: item.question, answer: item.answer })), ]) +// The Cloud address. Self-hosted platforms answer on their own domain, so the block +// takes one (host-swap) rather than telling the reader in prose to edit what they +// have just copied. const ENDPOINT = 'https://app.flowfuse.com/mcp' const STEP1 = { @@ -326,7 +329,7 @@ onUnmounted(() => {
- +
diff --git a/src/css/style.css b/src/css/style.css index 819311f998..9cfa7d8e7d 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -512,6 +512,49 @@ dl.message-properties > dd { @apply rounded-lg p-4; } +/* Self-hosted address control. Under the block, not inside it: the block is what you + copy, and this changes what that is. Closed by default as a single line of link + text, because most readers are on Cloud and the address is already right for them. */ +.ff-command__host { + margin-bottom: 0.5rem; +} + +.ff-command__host-toggle, +.ff-command__host-reset { + font-size: 0.75rem; + line-height: 1.5; + font-weight: 500; + text-decoration: underline; + color: var(--color-indigo-600); +} + +.ff-command__host-toggle:hover, +.ff-command__host-reset:hover { + color: var(--color-indigo-800); +} + +.ff-command__host-field { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.ff-command__host-input { + flex: 1 1 12rem; + min-width: 0; + font-size: 0.875rem; + color: var(--color-gray-900); + border: 1px solid var(--color-gray-300); + @apply rounded px-3 py-2; +} + +.ff-command__host-input:focus { + outline: none; + border-color: var(--color-indigo-500); + box-shadow: 0 0 0 1px var(--color-indigo-500); +} + /* Event banner */