Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Local app (`packages/web/src/App.tsx`): "Share" button in the header that copies a self-contained share URL of the currently-loaded flow to the clipboard. The URL points at the public Pages playground (`https://naorsabag.github.io/openhop/#<encoded>`), reusing the same v1 fragment format the playground already decodes — so a link copied from `npm run dev` opens cleanly for anyone offsite without needing a local server. New `buildPagesShareUrl` helper in `lib/share-url.ts` centralizes the destination so future renames stay in one place.

## [0.3.2] - 2026-05-15

### Added
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ Flows are authored in **compact YAML**, not JSON, so the payload the agent emits

OpenHop is local-first — no hosted backend, no flow storage. To share a flow, open the [playground](https://naorsabag.github.io/openhop/), paste your YAML and hit **Save**: the page compresses the flow into the URL hash and copies a self-contained link to your clipboard. Nothing is uploaded — URL fragments stay in the browser.

Running the local app? The header's **Share** button does the same thing for the flow you have open — it builds a playground URL of the form `https://naorsabag.github.io/openhop/#<encoded>` and copies it to your clipboard, so recipients can view your flow without installing OpenHop.

For flows too large to fit in a URL, share the YAML file directly.

## Install Options
Expand Down
17 changes: 16 additions & 1 deletion packages/web/__tests__/share-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import LZString from 'lz-string'
import YAML from 'yaml'
import { deflateSync } from 'fflate'
import { parseFlowYaml } from '@openhop/shared'
import { buildShareUrl, decodeFragment, encodeFragment } from '../src/lib/share-url'
import {
buildPagesShareUrl,
buildShareUrl,
decodeFragment,
encodeFragment,
PAGES_SHARE_BASE,
PAGES_SHARE_ORIGIN,
} from '../src/lib/share-url'

function toBase64Url(bytes: Uint8Array): string {
let bin = ''
Expand Down Expand Up @@ -104,4 +111,12 @@ describe('share-url encode/decode', () => {
expect(dev.endsWith(`#${expectedHash}`)).toBe(true)
expect(pages.endsWith(`#${expectedHash}`)).toBe(true)
})

it('buildPagesShareUrl always points at the Pages playground (used by the local app)', () => {
const url = buildPagesShareUrl(SAMPLE_YAML)
expect(url.startsWith(`${PAGES_SHARE_ORIGIN}${PAGES_SHARE_BASE}#`)).toBe(true)
// Decodes back to the same flow regardless of which host produced the link.
const hash = url.split('#')[1]
expect(YAML.parse(decodeFragment(hash) ?? '')).toEqual(YAML.parse(SAMPLE_YAML))
})
})
82 changes: 82 additions & 0 deletions packages/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const FlowEditorModal = lazy(() =>
import('./components/FlowEditorModal').then((m) => ({ default: m.FlowEditorModal }))
)
import { buildStarterYaml } from './lib/starter-yaml'
import { buildPagesShareUrl } from './lib/share-url'
import { isMobileViewport } from './lib/mobile'
import { useFlowList, useFlowData } from './hooks/useFlowPolling'
import { useFlowMutations } from './hooks/useFlowMutations'
Expand All @@ -20,6 +21,23 @@ interface FlowNavItem {
resumeFromStep?: number // step index to resume from when returning to this level
}

// Android-style share glyph: three nodes connected by two edges (one
// anchor on the left, two on the right). Content runs edge-to-edge in
// the viewBox so the flex gap matches "Share"'s left padding. `display:
// block` overrides SVG's default baseline alignment, which otherwise
// drops the icon below the pixel-font's optical midline.
function ShareIcon() {
return (
<svg width={11} height={11} viewBox="0 0 16 16" aria-hidden="true" style={{ display: 'block' }}>
<line x1={3} y1={8} x2={13} y2={3} stroke="currentColor" strokeWidth={1.4} />
<line x1={3} y1={8} x2={13} y2={13} stroke="currentColor" strokeWidth={1.4} />
<circle cx={3} cy={8} r={2.4} fill="currentColor" />
<circle cx={13} cy={3} r={2.4} fill="currentColor" />
<circle cx={13} cy={13} r={2.4} fill="currentColor" />
</svg>
)
}

function App() {
// Read flow ID from URL path: /flow/{id}
const [selectedFlowId, setSelectedFlowId] = useState<string | null>(() => {
Expand Down Expand Up @@ -172,6 +190,38 @@ function App() {
setEditor({ mode: 'closed' })
}, [])

// Toast for share-link feedback. Auto-clears via the timer ref so a
// rapid second click resets the countdown instead of stacking.
const [toast, setToast] = useState<string | null>(null)
const toastTimerRef = useRef<number | null>(null)
const showToast = useCallback((msg: string) => {
setToast(msg)
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current)
toastTimerRef.current = window.setTimeout(() => setToast(null), 2400)
}, [])

// Share URL points at the Pages playground (the only host that can
// decode it without a local server). Shares the top-level flow — sub-flow
// drilldown state is recipient-side navigation, not part of the data.
const apiFlowRef = useRef<Flow | null>(null)
useEffect(() => {
apiFlowRef.current = apiFlow
}, [apiFlow])
const handleShareFlow = useCallback(async () => {
const flow = apiFlowRef.current
if (!flow) return
const yamlText = YAML.stringify({ meta: flow.meta, flow: flow.flow })
const url = buildPagesShareUrl(yamlText)
try {
await navigator.clipboard.writeText(url)
showToast('Copied playground share URL to clipboard.')
} catch {
// Some browsers / iframe contexts block clipboard. Surface the URL
// via prompt so the user can copy it by hand.
window.prompt('Copy this share URL:', url)
}
}, [showToast])

const [playing, setPlaying] = useState(false)
const [flowStack, setFlowStack] = useState<FlowNavItem[]>([])

Expand Down Expand Up @@ -388,6 +438,21 @@ function App() {
OpenHop
</h1>
</div>
<div className="flex items-center gap-2">
{apiFlow && (
<button
onClick={handleShareFlow}
data-testid="header-share"
aria-label="Share flow"
title="Copy a self-contained share URL for the Pages playground"
className="openhop-header-btn font-pixel text-xs px-3 py-1 border transition-colors inline-flex items-center gap-1.5"
style={{ fontSize: 10 }}
>
<ShareIcon />
Share
</button>
)}
</div>
{/* Inspector toggle moved to a bookmark tab on the canvas's right edge. */}
</header>

Expand Down Expand Up @@ -560,6 +625,23 @@ function App() {
/>
</Suspense>
)}

{toast && (
<div
role="status"
aria-live="polite"
className="fixed bottom-4 left-1/2 -translate-x-1/2 font-terminal text-xs px-3 py-2"
style={{
background: '#1a1a2e',
border: '1px solid #7df9ff',
color: '#7df9ff',
borderRadius: 4,
zIndex: 200,
}}
>
{toast}
</div>
)}
</div>
)
}
Expand Down
10 changes: 10 additions & 0 deletions packages/web/src/lib/share-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,13 @@ export function buildShareUrl(yamlText: string, origin: string, baseUrl: string)
// baseUrl ends with '/' (Vite convention), so concat without normalization.
return `${origin}${baseUrl}#${fragment}`
}

// Public GitHub Pages playground — the only host that can decode share
// fragments without a local server. The local app uses this as the share
// target so URLs copied from `npm run dev` still work for anyone offsite.
export const PAGES_SHARE_ORIGIN = 'https://naorsabag.github.io'
export const PAGES_SHARE_BASE = '/openhop/'

export function buildPagesShareUrl(yamlText: string): string {
return buildShareUrl(yamlText, PAGES_SHARE_ORIGIN, PAGES_SHARE_BASE)
}
Loading