diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx
index 5eb908667..7b4c7635e 100644
--- a/client/src/components/Map/MapViewGL.test.tsx
+++ b/client/src/components/Map/MapViewGL.test.tsx
@@ -344,6 +344,52 @@ describe('MapViewGL', () => {
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
})
+ it('FE-COMP-MAPVIEWGL-012: a right-button rotate/pitch drag does not open Add-Place on release (#1398)', async () => {
+ const onContext = vi.fn()
+ render()
+ await act(async () => {})
+ const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
+ act(() => {
+ // mapbox-gl (unlike maplibre) still emits contextmenu after a right-drag
+ // on Windows — the movement guard must drop it.
+ glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
+ handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 140, clientY: 90 }) })
+ })
+ expect(onContext).not.toHaveBeenCalled()
+ // ...while a stationary right-click still fires.
+ act(() => {
+ glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
+ handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 11, clientY: 10 }) })
+ })
+ expect(onContext).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-COMP-MAPVIEWGL-013: a stale long-press suppression never swallows a later real tap (#1398)', async () => {
+ vi.useFakeTimers()
+ try {
+ const onContext = vi.fn()
+ const onMapClick = vi.fn()
+ render()
+ await act(async () => {})
+ // Long-press fires (arms the suppression), but no click follows.
+ act(() => {
+ glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
+ vi.advanceTimersByTime(650)
+ })
+ expect(onContext).toHaveBeenCalledTimes(1)
+ // The NEXT gesture starts fresh: its tap must reach the map click handler.
+ const clickHandler = glMap.on.mock.calls.find(c => c[0] === 'click')?.[1] as (e: unknown) => void
+ act(() => {
+ glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 80, clientY: 90 }]))
+ glCanvasContainer.dispatchEvent(touchEvent('touchend', []))
+ clickHandler({ lngLat: { lat: 3, lng: 4 }, originalEvent: { target: glCanvasContainer } })
+ })
+ expect(onMapClick).toHaveBeenCalledWith({ latlng: { lat: 3, lng: 4 } })
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
it('FE-COMP-MAPVIEWGL-010: middle-click still opens Add-Place (#1398 regression guard)', async () => {
const onContext = vi.fn()
render()
diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx
index e2b25d78e..f16b84962 100644
--- a/client/src/components/Map/MapViewGL.tsx
+++ b/client/src/components/Map/MapViewGL.tsx
@@ -501,14 +501,25 @@ export function MapViewGL({
// does, which is why the OSM map already worked on mobile).
const canvas = map.getCanvasContainer()
let lastContextFire = 0
- const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent) => {
+ const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent): boolean => {
// Android fires a native contextmenu for a long-press on top of our own
// timer — dedupe so the form doesn't open twice.
- if (Date.now() - lastContextFire < 700) return
+ if (Date.now() - lastContextFire < 700) return false
lastContextFire = Date.now()
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
+ return true
}
+ // MapLibre swallows the map contextmenu at the end of a right-button
+ // rotate/pitch drag, but mapbox-gl does NOT — and on Windows the DOM
+ // contextmenu arrives after mouseup, so every rotate would end by opening
+ // the Add-Place form. Track the right-button press position and drop a
+ // contextmenu whose pointer travelled like a drag rather than a click.
+ let rightDownAt: { x: number; y: number } | null = null
const onAuxDown = (ev: MouseEvent) => {
+ if (ev.button === 2) {
+ rightDownAt = { x: ev.clientX, y: ev.clientY }
+ return
+ }
if (ev.button !== 1) return
ev.preventDefault()
const rect = canvas.getBoundingClientRect()
@@ -522,6 +533,9 @@ export function MapViewGL({
canvas.addEventListener('mousedown', onAuxDown)
canvas.addEventListener('auxclick', onAuxClick)
map.on('contextmenu', (e: { lngLat: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
+ const down = rightDownAt
+ rightDownAt = null
+ if (down && Math.hypot(e.originalEvent.clientX - down.x, e.originalEvent.clientY - down.y) > 5) return
fireContext(e.lngLat, e.originalEvent)
})
// Touch long-press: 600 ms hold (Leaflet's tapHold feel) with a 10 px
@@ -534,6 +548,10 @@ export function MapViewGL({
lpStart = null
}
const onTouchStart = (ev: TouchEvent) => {
+ // A fresh gesture clears a stale suppression flag: not every long-press
+ // is followed by a click (finger drag after the hold, Android's native
+ // contextmenu path), and the flag must never swallow a later real tap.
+ suppressNextClick = false
if (ev.touches.length !== 1) { cancelLongPress(); return }
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
const t = ev.touches[0]
@@ -544,8 +562,9 @@ export function MapViewGL({
const rect = canvas.getBoundingClientRect()
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top])
lpStart = null
- suppressNextClick = true
- fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)
+ // Only suppress the tap when OUR fire opened the form — if the native
+ // contextmenu beat us to it (dedupe), no click needs swallowing.
+ if (fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)) suppressNextClick = true
}, 600)
}
const onTouchMove = (ev: TouchEvent) => {
diff --git a/package.json b/package.json
index 9a77a1cd5..c0e931591 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,10 @@
"concurrently": "^10.0.3",
"unrun": "^0.3.1"
},
+ "comment:scarfSettings": "swagger-ui-dist ships @scarf/scarf install-time analytics; TREK sends no telemetry, so it stays off.",
+ "scarfSettings": {
+ "enabled": false
+ },
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
"overrides": {
"react": "19.2.6",
diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts
index 79502853e..a86ca7f15 100644
--- a/server/src/mcp/index.ts
+++ b/server/src/mcp/index.ts
@@ -110,7 +110,12 @@ const KEEPALIVE_MS = resolveKeepaliveMs(process.env.MCP_SSE_KEEPALIVE);
* stream is still connected.
*/
function armSseKeepalive(res: Response, touch?: () => void): void {
- if (KEEPALIVE_MS <= 0) return;
+ // With pings disabled (MCP_SSE_KEEPALIVE=0) an open stream must STILL count
+ // as session activity, or the sweep would evict a live idle client — keep
+ // the interval for touch() and only skip the writes.
+ const writePings = KEEPALIVE_MS > 0;
+ if (!writePings && !touch) return;
+ const intervalMs = writePings ? KEEPALIVE_MS : 25_000;
const timer = setInterval(() => {
if (!res.headersSent) return;
const ct = String(res.getHeader('content-type') ?? '');
@@ -118,9 +123,9 @@ function armSseKeepalive(res: Response, touch?: () => void): void {
clearInterval(timer);
return;
}
- res.write(': keepalive\n\n');
+ if (writePings) res.write(': keepalive\n\n');
touch?.();
- }, KEEPALIVE_MS);
+ }, intervalMs);
timer.unref();
res.once('close', () => clearInterval(timer));
}
diff --git a/wiki/Budget-Tracking.md b/wiki/Budget-Tracking.md
index 9223d6653..3c99b8563 100644
--- a/wiki/Budget-Tracking.md
+++ b/wiki/Budget-Tracking.md
@@ -16,7 +16,7 @@ Open the **Budget** tab inside the trip planner. The tab is only visible when th
## Currency
-Use the currency picker in the Budget toolbar to select one currency for the entire trip. 46 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
+Use the currency picker in the Budget toolbar to select one currency for the entire trip. 47 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, KGS, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
## Categories
diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md
index eb2813f4b..4d3b22bad 100644
--- a/wiki/Environment-Variables.md
+++ b/wiki/Environment-Variables.md
@@ -186,7 +186,7 @@ For setup instructions, see [MCP-Overview](MCP-Overview).
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
| `MCP_SESSION_TTL` | Session idle timeout in seconds (max 86400) | `3600` |
-| `MCP_SSE_KEEPALIVE` | SSE keep-alive ping interval in seconds — keeps the stream alive through reverse proxies; `0` disables it | `25` |
+| `MCP_SSE_KEEPALIVE` | SSE keep-alive ping interval in seconds — keeps the stream alive through reverse proxies. `0` disables the pings; an open stream still refreshes the session's idle timeout. | `25` |
---