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"
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<string, unknown>
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<TelegramMessage[]>([])
const [messages, setMessages] = useState<ThreadMessage[]>([])
const [hasMore, setHasMore] = useState(false)
const [loading, setLoading] = useState(true)
const [loadingOlder, setLoadingOlder] = useState(false)
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(() => {
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])
// 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 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()
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)
}
}
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 (
<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>
<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 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>
</div>
</Card>
)
}
)}
if (error) {
return (
<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">
{error && (
<div className="flex-1 flex items-center justify-center h-80">
<span className="text-sm text-red-500">Error: {error}</span>
</div>
</Card>
)
}
)}
return (
<Card className="bg-card/40 p-4 flex flex-col">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Send className="h-4 w-4 text-primary" />
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Chat history</span>
</div>
<a href="/chat?session=telegram" className="text-xs text-primary hover:underline">Open chat </a>
</div>
{!loading && !error && (
<div
ref={scrollRef}
onScroll={onScroll}
className="h-80 overflow-y-auto pr-1 flex flex-col gap-2"
>
{hasMore && (
<button
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 ? (
<ul className="space-y-2 flex-1">
{messages.map((msg, i) => (
<li key={i} className="text-sm">
<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)}
{messages.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<span className="text-sm text-muted-foreground">
No messages yet
</span>
</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>
</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>
)}
</Card>