diff --git a/apps/web/components/tma/TelegramNativeProfile.tsx b/apps/web/components/tma/TelegramNativeProfile.tsx index 6be0a1b..cc1ed76 100644 --- a/apps/web/components/tma/TelegramNativeProfile.tsx +++ b/apps/web/components/tma/TelegramNativeProfile.tsx @@ -1,9 +1,26 @@ "use client"; -import { FormEvent, ReactNode, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, ReactNode, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import VisualExecutorCursor from "@/components/tma/VisualExecutorCursor"; +import { + clampToViewport, + initialContext, + isActive, + phaseColor, + reduce, + resolveTargetSelector, + saveAudit, + telegramMutation, + EXECUTION_MODE, + DEMO_OLD_BIO, + DEMO_NEW_BIO, + type AuditEvent, + type ExecContext, + type ExecutionPhase, + type Point, +} from "@/lib/visualExecutor"; type OperatorPanelState = "closed" | "compact" | "expanded"; -type ActionState = "pending" | "cancelled" | "approved"; type OperatorMessage = { id: string; role: "assistant" | "user"; title?: string; text: string }; type TelegramWebAppUser = { id?: number; first_name?: string; last_name?: string; username?: string; photo_url?: string }; @@ -22,15 +39,29 @@ const initialMessages: OperatorMessage[] = [ }, ]; +type Overlay = { rect: DOMRect; point: Point; label: string; phase: ExecutionPhase }; + +const nowISO = () => new Date().toISOString(); + +function prefersReducedMotion() { + return typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; +} + export default function TelegramNativeProfile() { const [panel, setPanel] = useState("compact"); - const [actionState, setActionState] = useState("pending"); const [input, setInput] = useState(""); const [messages, setMessages] = useState(initialMessages); const [telegramUser, setTelegramUser] = useState(null); + const [showAudit, setShowAudit] = useState(false); + const [overlay, setOverlay] = useState(null); const historyRef = useRef(null); + const rootRef = useRef(null); const profileScrollRef = useRef(0); + // The approval-gated visual executor. initialContext() starts in + // "pending_approval" so the existing action card renders as before. + const [ctx, dispatch] = useReducer(reduce, undefined, () => initialContext()); + useEffect(() => { const webApp = window.Telegram?.WebApp; webApp?.ready?.(); @@ -41,7 +72,77 @@ export default function TelegramNativeProfile() { useEffect(() => { if (panel === "closed") return; requestAnimationFrame(() => historyRef.current?.scrollTo({ top: historyRef.current.scrollHeight, behavior: "smooth" })); - }, [messages, panel, actionState]); + }, [messages, panel, ctx.state, ctx.currentStepIndex]); + + // Persist the audit trail (structured for a future server audit API). + useEffect(() => { + saveAudit(ctx.audit); + }, [ctx.audit]); + + // --- Executor driver ----------------------------------------------------- + // approved → executing (kick off the plan after the approval is recorded). + useEffect(() => { + if (ctx.state !== "approved") return; + const timer = window.setTimeout(() => dispatch({ type: "START", at: nowISO() }), 480); + return () => window.clearTimeout(timer); + }, [ctx.state]); + + // executing → advance one step per tick. Pausing flips state away from + // "executing", which tears down this timer; resuming re-arms it. + useEffect(() => { + if (ctx.state !== "executing") return; + const delay = prefersReducedMotion() ? 360 : 1050; + const timer = window.setTimeout(() => dispatch({ type: "ADVANCE", at: nowISO() }), delay); + return () => window.clearTimeout(timer); + }, [ctx.state, ctx.currentStepIndex]); + + // --- Cursor / highlight targeting --------------------------------------- + // While the executor is active, continuously track the active step's target. + // Resolution goes ONLY through the allowlisted attribute selector, scoped to + // this component's root — no text search, no arbitrary CSS. We poll on a short + // interval rather than requestAnimationFrame so tracking keeps working even + // when the page is backgrounded (rAF is paused for hidden tabs); the cursor's + // CSS transition keeps motion smooth between samples. reduced-motion disables + // that transition in the cursor component itself. + const currentStep = ctx.steps[ctx.currentStepIndex]; + const targetId = isActive(ctx) && ctx.state !== "approved" ? currentStep?.targetId : undefined; + const stepLabel = currentStep?.label ?? ""; + const stepPhase: ExecutionPhase = currentStep?.phase ?? "analysis"; + + useEffect(() => { + if (!targetId) { + setOverlay(null); + return; + } + const selector = resolveTargetSelector(targetId); + if (!selector) { + setOverlay(null); + return; + } + let lastKey = ""; + const measure = () => { + const el = rootRef.current?.querySelector(selector) as HTMLElement | null; + if (!el) return; + const rect = el.getBoundingClientRect(); + const key = `${Math.round(rect.left)}:${Math.round(rect.top)}:${Math.round(rect.width)}:${Math.round(rect.height)}`; + if (key === lastKey) return; + lastKey = key; + const raw = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + const point = clampToViewport(raw, { + width: window.innerWidth, + height: window.innerHeight, + safeTop: 16, + safeBottom: 16, + safeLeft: 8, + safeRight: 8, + margin: 18, + }); + setOverlay({ rect, point, label: stepLabel, phase: stepPhase }); + }; + measure(); + const interval = window.setInterval(measure, 120); + return () => window.clearInterval(interval); + }, [targetId, stepLabel, stepPhase]); const displayName = useMemo(() => { const name = [telegramUser?.first_name, telegramUser?.last_name].filter(Boolean).join(" ").trim(); @@ -51,12 +152,22 @@ export default function TelegramNativeProfile() { const username = telegramUser?.username ? `@${telegramUser.username}` : "@EpicStarAi"; const telegramId = telegramUser?.id ? String(telegramUser.id) : "—"; + // Show the staged draft while the (simulated) form is open, otherwise the + // committed local-demo bio. Only COMMIT_SAVE moves draftBio → bio. + const showingDraft = + ctx.formOpen && + ctx.draftBio !== ctx.bio && + (ctx.state === "executing" || ctx.state === "paused" || ctx.state === "awaiting_final_confirmation"); + const bioValue = showingDraft ? ctx.draftBio : ctx.bio; + function openPanel(next: OperatorPanelState) { profileScrollRef.current = window.scrollY; setPanel(next); } - function closePanel() { + // "Скрыть окно" / close — hides the operator WITHOUT cancelling: execution + // context is preserved and can be resumed via "Вернуть оператора". + function hidePanel() { setPanel("closed"); requestAnimationFrame(() => window.scrollTo({ top: profileScrollRef.current })); } @@ -65,6 +176,8 @@ export default function TelegramNativeProfile() { event.preventDefault(); const value = input.trim(); if (!value) return; + // Read-only path: appends chat only. It must NOT start or mutate the executor. + dispatch({ type: "READ_ONLY_MESSAGE" }); setMessages((current) => [ ...current, { id: crypto.randomUUID(), role: "user", text: value }, @@ -78,8 +191,10 @@ export default function TelegramNativeProfile() { setInput(""); } + const executorRunning = isActive(ctx) || ctx.state === "completed" || ctx.state === "cancelled" || ctx.state === "failed"; + return ( -
+
@@ -109,17 +224,25 @@ export default function TelegramNativeProfile() {
} label="Выбрать фото" /> - } label="Изменить" /> + } label="Изменить" targetId="profile-edit-button" /> } label="Настройки" />
-
+

id: {telegramId}

God's Eye
- +
@@ -138,11 +261,52 @@ export default function TelegramNativeProfile() {
+ {/* Action highlight overlay — a separate element that never mutates the + target's own styles and is removed when the step changes. */} + {overlay ? ( +