From 9bd1db36e49baa05459fa3ab75e459edff66f449 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 27 Aug 2026 14:12:44 +0200 Subject: [PATCH 1/2] /ai: take a self-hosted address in the command block --- nuxt/components/content/FfCommand.vue | 133 ++++++++++++++++++++++---- nuxt/pages/ai/index.vue | 5 +- src/css/style.css | 43 +++++++++ 3 files changed, 160 insertions(+), 21 deletions(-) diff --git a/nuxt/components/content/FfCommand.vue b/nuxt/components/content/FfCommand.vue index 8c5b5a5241..a3f93e6fb6 100644 --- a/nuxt/components/content/FfCommand.vue +++ b/nuxt/components/content/FfCommand.vue @@ -17,12 +17,67 @@ 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. +const host = 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 shownCommand = computed(() => { + if (!props.hostSwap || !host.value) return props.command + return props.command.replace(/^(https?:\/\/)[^/\s]+/i, `$1${host.value}`) +}) + +function setHost (value: string) { + host.value = normaliseHost(value) + 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 useCloud () { + setHost('') + 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 || host.value) return + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + host.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 +97,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 +118,53 @@ 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 8bfa5679cd..88551b8f6f 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -511,6 +511,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-top: 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 */ From c2e267037f4b28efe410a3caf5e72845728e9fad Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 27 Aug 2026 14:41:01 +0200 Subject: [PATCH 2/2] /ai: keep the typed address as typed, and put the field above the block --- nuxt/components/content/FfCommand.vue | 72 ++++++++++++++++----------- src/css/style.css | 2 +- 2 files changed, 44 insertions(+), 30 deletions(-) diff --git a/nuxt/components/content/FfCommand.vue b/nuxt/components/content/FfCommand.vue index a3f93e6fb6..141988e668 100644 --- a/nuxt/components/content/FfCommand.vue +++ b/nuxt/components/content/FfCommand.vue @@ -31,7 +31,12 @@ 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. -const host = useState('ff-command-host', () => '') +// +// 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' @@ -41,13 +46,14 @@ 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 setHost (value: string) { - host.value = normaliseHost(value) +function remember () { try { if (host.value) localStorage.setItem(STORAGE_KEY, host.value) else localStorage.removeItem(STORAGE_KEY) @@ -57,8 +63,14 @@ function setHost (value: string) { } } +function onHostInput (event: Event) { + typed.value = (event.target as HTMLInputElement).value + remember() +} + function useCloud () { - setHost('') + typed.value = '' + remember() editing.value = false } @@ -66,11 +78,11 @@ function useCloud () { // address, so reading storage any earlier would make the server and client markup // disagree. onMounted(() => { - if (!props.hostSwap || host.value) return + if (!props.hostSwap || typed.value) return try { const stored = localStorage.getItem(STORAGE_KEY) if (stored) { - host.value = stored + typed.value = stored editing.value = true } } catch { @@ -118,28 +130,11 @@ onUnmounted(() => clearTimeout(resetTimer)) diff --git a/src/css/style.css b/src/css/style.css index 88551b8f6f..56a5192050 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -515,7 +515,7 @@ dl.message-properties > dd { 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-top: 0.5rem; + margin-bottom: 0.5rem; } .ff-command__host-toggle,