diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
index ebfc1943..5573133c 100644
--- a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
+++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx
@@ -1,10 +1,10 @@
"use client";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
-import { ChatPanel } from "@/components/site-finder/ChatPanel";
+import { ChatDock } from "@/components/site-finder/ChatDock";
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
@@ -222,11 +222,13 @@ export function AnalysisPageContent({ cad }: Props) {
Seeded из анализа (geom + financial_estimate), считается по
запросу; result lazy-mounted (Leaflet). */}
-
- {/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
-
+
+ {/* Chat — grounded parcel-chat over the §22 forecast (#958). Docked
+ right-side floating panel (position: fixed), доступен при любом
+ скролле; mount once, placement in the tree is irrelevant. */}
+
);
}
@@ -404,11 +406,75 @@ const NAV_GROUPS: NavGroup[] = [
},
];
+// Top-level section anchors in scroll order — observed by the scrollspy.
+// Children (3.x / 6.x) are intentionally NOT observed: their parent stays lit
+// while the user is anywhere inside the section, which reads cleaner.
+const TOP_LEVEL_IDS: string[] = NAV_GROUPS.flatMap((g) =>
+ g.items.map((i) => i.id),
+);
+
+// Sticky chrome offset (header + breadcrumb). The jump lands the section title
+// just below this; matches scrollMarginTop used on the section cards.
+const SCROLL_OFFSET = 80;
+
+// Robust jump: bare scrollIntoView({behavior:"smooth"}) can land short when
+// async content (§6 forecast loads ~20-40s later) shifts heights mid-animation,
+// and it ignores the sticky offset. Compute an absolute target relative to the
+// document and scroll there, accounting for the sticky chrome.
+function scrollToSection(id: string) {
+ const el = document.getElementById(id);
+ if (!el) return;
+ const top = el.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET;
+ window.scrollTo({ top, behavior: "smooth" });
+}
+
function AnalysisSidebarNav() {
- function scrollTo(id: string) {
- const el = document.getElementById(id);
- if (el) el.scrollIntoView({ behavior: "smooth" });
- }
+ // Scrollspy: highlight whichever top-level section is currently near the top
+ // of the viewport. Default to the first so something is lit before any scroll.
+ const [activeId, setActiveId] = useState(TOP_LEVEL_IDS[0]);
+ const observerRef = useRef(null);
+
+ useEffect(() => {
+ // SSR / unsupported-env guard.
+ if (
+ typeof window === "undefined" ||
+ typeof IntersectionObserver === "undefined"
+ ) {
+ return;
+ }
+
+ const targets = TOP_LEVEL_IDS.map((id) =>
+ document.getElementById(id),
+ ).filter((el): el is HTMLElement => el !== null);
+ if (targets.length === 0) return;
+
+ // Track the set of currently-intersecting sections; the active one is the
+ // first (topmost in scroll order) still inside the band. rootMargin pushes
+ // the band's top down past the sticky chrome and shrinks its bottom so the
+ // "active" section is the one near the top of the viewport — not whatever
+ // merely peeks in from below.
+ const visible = new Set();
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) visible.add(entry.target.id);
+ else visible.delete(entry.target.id);
+ }
+ const next = TOP_LEVEL_IDS.find((id) => visible.has(id));
+ if (next) setActiveId(next);
+ },
+ { root: null, rootMargin: "-72px 0px -60% 0px", threshold: 0 },
+ );
+
+ targets.forEach((el) => observer.observe(el));
+ observerRef.current = observer;
+
+ return () => {
+ observer.disconnect();
+ observerRef.current = null;
+ };
+ }, []);
return (
diff --git a/frontend/src/components/site-finder/ChatDock.tsx b/frontend/src/components/site-finder/ChatDock.tsx
new file mode 100644
index 00000000..d6fee1c7
--- /dev/null
+++ b/frontend/src/components/site-finder/ChatDock.tsx
@@ -0,0 +1,155 @@
+"use client";
+
+/**
+ * ChatDock — floating right-docked wrapper around for the Site
+ * Finder analysis report (#958 follow-up).
+ *
+ * The chat used to be mounted inline at the bottom of the report; that meant the
+ * user had to scroll all the way down to ask anything. ChatDock keeps it
+ * always reachable: a fixed launcher (bottom-right) opens a fixed popover panel
+ * that stays put while the report scrolls.
+ *
+ * Open/close:
+ * - launcher click → toggles the panel open
+ * - close button (X) → closes
+ * - Esc → closes (keydown listener active only while open, cleaned up)
+ *
+ * Double-card avoidance: already renders its own bordered
+ * card with the «Чат по участку» header. So the dock's own chrome is just a thin
+ * header bar carrying the X close button (NO duplicate title) — ChatPanel's own
+ * header provides the title inside the scrollable body. No nested boxes.
+ *
+ * box-shadow is allowed here (floating popover / launcher), unlike in-page cards.
+ */
+
+import { useEffect, useState } from "react";
+import { MessageSquare, X } from "lucide-react";
+
+import { ChatPanel } from "@/components/site-finder/ChatPanel";
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+interface Props {
+ cadNum: string;
+}
+
+// ── ChatDock ───────────────────────────────────────────────────────────────────
+
+export function ChatDock({ cadNum }: Props) {
+ const [open, setOpen] = useState(false);
+
+ // Esc closes the panel (listener active only while open).
+ useEffect(() => {
+ if (!open) return;
+ function onKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape") setOpen(false);
+ }
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [open]);
+
+ // ── Collapsed: floating launcher ─────────────────────────────────────────────
+
+ if (!open) {
+ return (
+
+ );
+ }
+
+ // ── Expanded: floating popover panel ─────────────────────────────────────────
+
+ return (
+
+ {/* Thin header bar — close affordance only. The title lives in ChatPanel's
+ own header below, so there's no duplicate heading / nested card. */}
+
+
+
+
+ {/* Scrollable body — ChatPanel renders normally (its own card + header). */}
+