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
This commit is contained in:
Manohar 2026-06-10 02:47:03 +00:00
parent 438488c8c8
commit 572418f0ea
2 changed files with 224 additions and 92 deletions

View file

@ -0,0 +1,34 @@
/**
* /api/chat/telegram-thread proxy for the bridge's Telegram mirror.
*
* GET ?limit=50&before=<seq>
* 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 },
);
}
}

View file

@ -1,120 +1,218 @@
"use client" "use client"
import { useEffect, useState } from "react" /**
import { Card } from "@/components/ui/card" * TelegramThreadCard live mirror of the Telegram conversation with Tiger.
import { Send } from "lucide-react" *
* 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 { import { useCallback, useEffect, useRef, useState } from "react"
role: string import { Card } from "@/components/ui/card"
content: string import { Send, ChevronUp, RefreshCw } from "lucide-react"
timestamp: number
meta?: Record<string, unknown> 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() { export function TelegramThreadCard() {
const [messages, setMessages] = useState<TelegramMessage[]>([]) const [messages, setMessages] = useState<ThreadMessage[]>([])
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadingOlder, setLoadingOlder] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(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<ThreadResponse> => {
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(() => { useEffect(() => {
fetch("/api/chat/history?limit=5") fetchPage()
.then(r => r.json()) .then((data) => {
.then(data => { if (data.ok && data.messages) {
if (data?.messages) { setMessages(data.messages)
setMessages(data.messages.slice(-5).reverse()) setHasMore(Boolean(data.hasMore))
} else {
setError(data.error || "No data")
} }
setLoading(false)
}) })
.catch(e => { .catch((e: Error) => setError(e.message))
console.error("Failed to load:", e) .finally(() => setLoading(false))
setError(e.message) }, [fetchPage])
setLoading(false)
// 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])
const hasData = messages.length > 0 // 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])
// Simple timestamp formatter const onScroll = () => {
const formatTime = (ts: number) => { const el = scrollRef.current
if (!ts) return "" if (!el) return
const diff = Date.now() - ts stickToBottom.current =
const mins = Math.floor(diff / 60000) el.scrollHeight - el.scrollTop - el.clientHeight < 40
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()
} }
// Simple truncate const loadOlder = async () => {
const truncate = (text: string, max = 40) => { if (loadingOlder || messages.length === 0) return
if (!text) return "" setLoadingOlder(true)
return text.length > max ? text.slice(0, max) + "..." : text 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)
}
}
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" })
} }
if (loading) {
return ( return (
<Card className="bg-card/40 p-4 flex flex-col"> <Card className="bg-card/40 p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Send className="h-4 w-4 text-primary" /> <Send className="h-4 w-4 text-primary" />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span> <span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
Telegram thread
</span>
{!loading && !error && (
<span className="ml-auto text-[10px] text-muted-foreground/60 flex items-center gap-1">
<RefreshCw className="h-3 w-3" /> live
</span>
)}
</div> </div>
<div className="flex-1 flex items-center justify-center">
{loading && (
<div className="flex-1 flex items-center justify-center h-80">
<span className="text-sm text-muted-foreground">Loading...</span> <span className="text-sm text-muted-foreground">Loading...</span>
</div> </div>
</Card> )}
)
}
if (error) { {error && (
return ( <div className="flex-1 flex items-center justify-center h-80">
<Card className="bg-card/40 p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3">
<Send className="h-4 w-4 text-primary" />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
</div>
<div className="flex-1 flex items-center justify-center">
<span className="text-sm text-red-500">Error: {error}</span> <span className="text-sm text-red-500">Error: {error}</span>
</div> </div>
</Card> )}
)
}
return ( {!loading && !error && (
<Card className="bg-card/40 p-4 flex flex-col"> <div
<div className="flex items-center justify-between mb-3"> ref={scrollRef}
<div className="flex items-center gap-2"> onScroll={onScroll}
<Send className="h-4 w-4 text-primary" /> className="h-80 overflow-y-auto pr-1 flex flex-col gap-2"
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Chat history</span> >
</div> {hasMore && (
<a href="/chat?session=telegram" className="text-xs text-primary hover:underline">Open chat </a> <button
</div> onClick={loadOlder}
disabled={loadingOlder}
className="self-center text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1 py-1 px-2 rounded hover:bg-muted/40 transition-colors"
>
<ChevronUp className="h-3 w-3" />
{loadingOlder ? "Loading..." : "Load older"}
</button>
)}
{hasData ? ( {messages.length === 0 && (
<ul className="space-y-2 flex-1"> <div className="flex-1 flex items-center justify-center">
{messages.map((msg, i) => ( <span className="text-sm text-muted-foreground">
<li key={i} className="text-sm"> No messages yet
<div className="flex items-baseline gap-2">
<span className="font-medium text-xs">
{msg.role === "user" ? "You" : "Tiger"}
</span>
<span className="text-[10px] text-muted-foreground">
{formatTime(msg.timestamp)}
</span> </span>
</div> </div>
<div className="text-xs text-muted-foreground/80 truncate"> )}
{truncate(msg.content)}
{messages.map((m) => (
<div
key={m.seq}
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
m.role === "user"
? "self-end bg-primary/15 text-foreground"
: "self-start bg-muted/50 text-foreground"
}`}
>
<div>{m.text}</div>
<div className="mt-1 text-[10px] text-muted-foreground/70 text-right">
{m.role === "user" ? "you" : "tiger"} · {formatTime(m.timestamp)}
</div>
</div> </div>
</li>
))} ))}
</ul>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center py-6 px-2">
<Send className="h-8 w-8 text-muted-foreground/30 mb-2" />
<p className="text-sm text-muted-foreground">No messages yet.</p>
<p className="text-[11px] text-muted-foreground/60 mt-1 max-w-[260px]">
Start a conversation to see messages here.
</p>
</div> </div>
)} )}
</Card> </Card>