From 572418f0ea6f9f815895c626d9cdb8a49f1daa73 Mon Sep 17 00:00:00 2001 From: Manohar Date: Wed, 10 Jun 2026 02:47:03 +0000 Subject: [PATCH] feat(dashboard): live Telegram thread mirror on homepage - /api/chat/telegram-thread proxy (token stays server-side) - TelegramThreadCard rewrite: scrollable full history, 'Load older' cursor pagination, 15s polling with referential-equality short-circuit, scroll-anchored prepends, auto-stick to bottom only when already there --- .../src/app/api/chat/telegram-thread/route.ts | 34 +++ .../src/components/telegram-thread-card.tsx | 282 ++++++++++++------ 2 files changed, 224 insertions(+), 92 deletions(-) create mode 100644 dashboard/src/app/api/chat/telegram-thread/route.ts diff --git a/dashboard/src/app/api/chat/telegram-thread/route.ts b/dashboard/src/app/api/chat/telegram-thread/route.ts new file mode 100644 index 0000000..987e183 --- /dev/null +++ b/dashboard/src/app/api/chat/telegram-thread/route.ts @@ -0,0 +1,34 @@ +/** + * /api/chat/telegram-thread — proxy for the bridge's Telegram mirror. + * + * GET ?limit=50&before= + * Same proxy pattern as /api/chat/history: the bridge bearer token stays on + * the server, the browser only ever talks to this route. + */ + +import { NextRequest, NextResponse } from "next/server"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export async function GET(request: NextRequest) { + const limit = request.nextUrl.searchParams.get("limit") || "50"; + const before = request.nextUrl.searchParams.get("before") || ""; + const qs = new URLSearchParams({ limit }); + if (before) qs.set("before", before); + + try { + const r = await fetch(`${BRIDGE_URL}/tiger/chat/telegram?${qs.toString()}`, { + headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` }, + cache: "no-store", + }); + const data = await r.json(); + return NextResponse.json(data, { status: r.status }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { ok: false, error: "Bridge unreachable", details: message }, + { status: 502 }, + ); + } +} diff --git a/dashboard/src/components/telegram-thread-card.tsx b/dashboard/src/components/telegram-thread-card.tsx index 775026b..661922f 100644 --- a/dashboard/src/components/telegram-thread-card.tsx +++ b/dashboard/src/components/telegram-thread-card.tsx @@ -1,122 +1,220 @@ "use client" -import { useEffect, useState } from "react" -import { Card } from "@/components/ui/card" -import { Send } from "lucide-react" +/** + * TelegramThreadCard — live mirror of the Telegram conversation with Tiger. + * + * Data source: /api/chat/telegram-thread → bridge /tiger/chat/telegram, which + * reads OpenClaw's native session transcript (the same file Tiger's context + * comes from). Both directions, full history, in sync by construction. + * + * Behaviour: + * - loads the newest page, scrolled to the bottom (like Telegram itself) + * - "Load older" at the top pages backwards through the entire history + * - polls for new messages every 15s; only repaints when something changed + * - preserves scroll position when older messages are prepended + */ -interface TelegramMessage { - role: string - content: string - timestamp: number - meta?: Record +import { useCallback, useEffect, useRef, useState } from "react" +import { Card } from "@/components/ui/card" +import { Send, ChevronUp, RefreshCw } from "lucide-react" + +interface ThreadMessage { + seq: number + role: "user" | "agent" + text: string + timestamp: string } +interface ThreadResponse { + ok: boolean + messages?: ThreadMessage[] + hasMore?: boolean + error?: string +} + +const PAGE_SIZE = 40 +const POLL_MS = 15_000 + export function TelegramThreadCard() { - const [messages, setMessages] = useState([]) + const [messages, setMessages] = useState([]) + const [hasMore, setHasMore] = useState(false) const [loading, setLoading] = useState(true) + const [loadingOlder, setLoadingOlder] = useState(false) const [error, setError] = useState(null) + const scrollRef = useRef(null) + // Tracks whether the user is parked at the bottom — only then do we + // auto-scroll on new messages, so reading history is never interrupted. + const stickToBottom = useRef(true) + + const fetchPage = useCallback( + async (before?: number): Promise => { + const qs = new URLSearchParams({ limit: String(PAGE_SIZE) }) + if (before) qs.set("before", String(before)) + const r = await fetch(`/api/chat/telegram-thread?${qs.toString()}`) + return r.json() + }, + [], + ) + + // Initial load useEffect(() => { - fetch("/api/chat/history?limit=5") - .then(r => r.json()) - .then(data => { - if (data?.messages) { - setMessages(data.messages.slice(-5).reverse()) + fetchPage() + .then((data) => { + if (data.ok && data.messages) { + setMessages(data.messages) + setHasMore(Boolean(data.hasMore)) + } else { + setError(data.error || "No data") } - setLoading(false) }) - .catch(e => { - console.error("Failed to load:", e) - setError(e.message) - setLoading(false) - }) - }, []) + .catch((e: Error) => setError(e.message)) + .finally(() => setLoading(false)) + }, [fetchPage]) - const hasData = messages.length > 0 + // Poll for new messages + useEffect(() => { + const t = setInterval(() => { + fetchPage() + .then((data) => { + if (!data.ok || !data.messages) return + setMessages((prev) => { + const newest = data.messages! + if ( + prev.length > 0 && + newest.length > 0 && + prev[prev.length - 1].seq === newest[newest.length - 1].seq + ) { + return prev // nothing new — keep referential equality, no repaint + } + // Merge: keep any older pages we already loaded, append the fresh tail. + const known = new Set(prev.map((m) => m.seq)) + const fresh = newest.filter((m) => !known.has(m.seq)) + return fresh.length > 0 ? [...prev, ...fresh] : prev + }) + }) + .catch(() => { /* transient poll errors are fine — next tick retries */ }) + }, POLL_MS) + return () => clearInterval(t) + }, [fetchPage]) - // Simple timestamp formatter - const formatTime = (ts: number) => { - if (!ts) return "" - const diff = Date.now() - ts - const mins = Math.floor(diff / 60000) - if (mins < 1) return "just now" - if (mins < 60) return `${mins}m ago` - const hours = Math.floor(mins / 60) - if (hours < 24) return `${hours}h ago` - return new Date(ts).toLocaleDateString() + // Auto-scroll to bottom on new tail messages (only if user was at bottom) + useEffect(() => { + const el = scrollRef.current + if (el && stickToBottom.current) { + el.scrollTop = el.scrollHeight + } + }, [messages]) + + const onScroll = () => { + const el = scrollRef.current + if (!el) return + stickToBottom.current = + el.scrollHeight - el.scrollTop - el.clientHeight < 40 } - // Simple truncate - const truncate = (text: string, max = 40) => { - if (!text) return "" - return text.length > max ? text.slice(0, max) + "..." : text + const loadOlder = async () => { + if (loadingOlder || messages.length === 0) return + setLoadingOlder(true) + const el = scrollRef.current + const prevHeight = el?.scrollHeight ?? 0 + try { + const data = await fetchPage(messages[0].seq) + if (data.ok && data.messages && data.messages.length > 0) { + setMessages((prev) => [...data.messages!, ...prev]) + setHasMore(Boolean(data.hasMore)) + // Keep the viewport anchored on the message the user was reading. + requestAnimationFrame(() => { + if (el) el.scrollTop = el.scrollHeight - prevHeight + }) + } else { + setHasMore(false) + } + } finally { + setLoadingOlder(false) + } } - if (loading) { - return ( - -
- - Telegram thread -
-
- Loading... -
-
- ) - } - - if (error) { - return ( - -
- - Telegram thread -
-
- Error: {error} -
-
- ) + const formatTime = (iso: string) => { + if (!iso) return "" + const d = new Date(iso) + const now = new Date() + const sameDay = d.toDateString() === now.toDateString() + return sameDay + ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + : d.toLocaleDateString([], { day: "numeric", month: "short" }) + + " " + + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) } return ( -
-
- - Chat history -
- Open chat → +
+ + + Telegram thread + + {!loading && !error && ( + + live + + )}
- {hasData ? ( -
    - {messages.map((msg, i) => ( -
  • -
    - - {msg.role === "user" ? "You" : "Tiger"} - - - {formatTime(msg.timestamp)} - + {loading && ( +
    + Loading... +
    + )} + + {error && ( +
    + Error: {error} +
    + )} + + {!loading && !error && ( +
    + {hasMore && ( + + )} + + {messages.length === 0 && ( +
    + + No messages yet + +
    + )} + + {messages.map((m) => ( +
    +
    {m.text}
    +
    + {m.role === "user" ? "you" : "tiger"} · {formatTime(m.timestamp)}
    -
    - {truncate(msg.content)} -
    -
  • +
))} - - ) : ( -
- -

No messages yet.

-

- Start a conversation to see messages here. -

)}
) -} \ No newline at end of file +}