feat: draggable LiveStreamWidget + README screenshots
Add click-and-drag repositioning to the LiveStreamWidget with position persistence via localStorage settings. Add dashboard screenshots to README from the marketing site. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -20,6 +20,24 @@ ClawPort is an open-source dashboard for managing, monitoring, and talking direc
|
||||
|
||||
No separate AI API keys needed. Everything routes through your OpenClaw gateway.
|
||||
|
||||
<img src="docs/screenshots/org-map.png" alt="Org Map" width="100%" />
|
||||
|
||||
<details>
|
||||
<summary><strong>More screenshots</strong></summary>
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| <img src="docs/screenshots/chat.png" alt="Agent Chat" /> | <img src="docs/screenshots/kanban.png" alt="Kanban Board" /> |
|
||||
| **Chat** -- streaming text, vision, voice, file attachments | **Kanban** -- drag-and-drop task board across agents |
|
||||
| <img src="docs/screenshots/pipelines.png" alt="Cron Pipelines" /> | <img src="docs/screenshots/cron-schedule.png" alt="Cron Schedule" /> |
|
||||
| **Pipelines** -- DAG visualization with health checks | **Schedule** -- weekly heatmap and job management |
|
||||
| <img src="docs/screenshots/activity.png" alt="Activity Console" /> | <img src="docs/screenshots/live-logs.png" alt="Live Logs" /> |
|
||||
| **Activity** -- historical log browser with JSON expansion | **Live Logs** -- real-time streaming widget |
|
||||
| <img src="docs/screenshots/costs.png" alt="Cost Dashboard" /> | <img src="docs/screenshots/memory.png" alt="Memory Browser" /> |
|
||||
| **Costs** -- token usage, anomalies, optimization insights | **Memory** -- team memory browser with markdown rendering |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -31,11 +31,12 @@ interface SettingsContextValue {
|
||||
setAgentOverride: (agentId: string, override: AgentOverride) => void
|
||||
clearAgentOverride: (agentId: string) => void
|
||||
getAgentDisplay: (agent: Agent) => AgentDisplay
|
||||
setLiveStreamPosition: (pos: { x: number; y: number } | null) => void
|
||||
resetAll: () => void
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsContextValue>({
|
||||
settings: { accentColor: null, portalName: null, portalSubtitle: null, portalEmoji: null, portalIcon: null, iconBgHidden: false, emojiOnly: false, operatorName: null, agentOverrides: {} },
|
||||
settings: { accentColor: null, portalName: null, portalSubtitle: null, portalEmoji: null, portalIcon: null, iconBgHidden: false, emojiOnly: false, operatorName: null, agentOverrides: {}, liveStreamPosition: null },
|
||||
setAccentColor: () => {},
|
||||
setPortalName: () => {},
|
||||
setPortalSubtitle: () => {},
|
||||
@@ -47,6 +48,7 @@ const SettingsContext = createContext<SettingsContextValue>({
|
||||
setAgentOverride: () => {},
|
||||
clearAgentOverride: () => {},
|
||||
getAgentDisplay: (agent) => ({ emoji: agent.emoji }),
|
||||
setLiveStreamPosition: () => {},
|
||||
resetAll: () => {},
|
||||
})
|
||||
|
||||
@@ -156,6 +158,13 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) {
|
||||
[settings, update],
|
||||
)
|
||||
|
||||
const setLiveStreamPosition = useCallback(
|
||||
(pos: { x: number; y: number } | null) => {
|
||||
update({ ...settings, liveStreamPosition: pos })
|
||||
},
|
||||
[settings, update],
|
||||
)
|
||||
|
||||
const getAgentDisplay = useCallback(
|
||||
(agent: Agent): AgentDisplay => {
|
||||
const override = settings.agentOverrides[agent.id]
|
||||
@@ -179,6 +188,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) {
|
||||
emojiOnly: false,
|
||||
operatorName: null,
|
||||
agentOverrides: {},
|
||||
liveStreamPosition: null,
|
||||
}
|
||||
update(defaults)
|
||||
}, [update])
|
||||
@@ -198,6 +208,7 @@ export function SettingsProvider({ children }: { children: React.ReactNode }) {
|
||||
setAgentOverride,
|
||||
clearAgentOverride,
|
||||
getAgentDisplay,
|
||||
setLiveStreamPosition,
|
||||
resetAll,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { LiveLogLine } from '@/lib/types'
|
||||
import { parseSSEBuffer } from '@/lib/sse'
|
||||
import { Play, Pause, Copy, Minimize2, Search, ChevronRight } from 'lucide-react'
|
||||
import { Play, Pause, Copy, Minimize2, Search, ChevronRight, GripHorizontal } from 'lucide-react'
|
||||
import { useSettings } from '@/app/settings-provider'
|
||||
|
||||
/* ── Constants ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -127,6 +128,94 @@ export function LiveStreamWidget() {
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
/* ── Drag-to-reposition ──────────────────────────────────── */
|
||||
|
||||
const { settings, setLiveStreamPosition } = useSettings()
|
||||
const widgetRef = useRef<HTMLDivElement>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const dragOffsetRef = useRef({ x: 0, y: 0 })
|
||||
|
||||
const savedPosition = settings.liveStreamPosition
|
||||
|
||||
const clampToViewport = useCallback((x: number, y: number) => {
|
||||
if (typeof window === 'undefined') return { x, y }
|
||||
const el = widgetRef.current
|
||||
const w = el ? el.offsetWidth : 440
|
||||
const h = el ? el.offsetHeight : 440
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, window.innerWidth - w)),
|
||||
y: Math.max(0, Math.min(y, window.innerHeight - h)),
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragStart = useCallback((clientX: number, clientY: number) => {
|
||||
const el = widgetRef.current
|
||||
if (!el) return
|
||||
draggingRef.current = true
|
||||
const rect = el.getBoundingClientRect()
|
||||
dragOffsetRef.current = { x: clientX - rect.left, y: clientY - rect.top }
|
||||
// Switch from bottom/right to left/top so drag math works
|
||||
el.style.left = `${rect.left}px`
|
||||
el.style.top = `${rect.top}px`
|
||||
el.style.right = 'auto'
|
||||
el.style.bottom = 'auto'
|
||||
el.style.transition = 'none'
|
||||
}, [])
|
||||
|
||||
const handleDragMove = useCallback((clientX: number, clientY: number) => {
|
||||
if (!draggingRef.current || !widgetRef.current) return
|
||||
const pos = clampToViewport(
|
||||
clientX - dragOffsetRef.current.x,
|
||||
clientY - dragOffsetRef.current.y,
|
||||
)
|
||||
widgetRef.current.style.left = `${pos.x}px`
|
||||
widgetRef.current.style.top = `${pos.y}px`
|
||||
}, [clampToViewport])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (!draggingRef.current || !widgetRef.current) return
|
||||
draggingRef.current = false
|
||||
widgetRef.current.style.transition = ''
|
||||
const rect = widgetRef.current.getBoundingClientRect()
|
||||
setLiveStreamPosition(clampToViewport(rect.left, rect.top))
|
||||
}, [clampToViewport, setLiveStreamPosition])
|
||||
|
||||
// Attach document-level listeners while dragging
|
||||
useEffect(() => {
|
||||
const onMouseMove = (e: MouseEvent) => handleDragMove(e.clientX, e.clientY)
|
||||
const onMouseUp = () => handleDragEnd()
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (draggingRef.current) e.preventDefault()
|
||||
handleDragMove(e.touches[0].clientX, e.touches[0].clientY)
|
||||
}
|
||||
const onTouchEnd = () => handleDragEnd()
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', onTouchEnd)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
document.removeEventListener('touchend', onTouchEnd)
|
||||
}
|
||||
}, [handleDragMove, handleDragEnd])
|
||||
|
||||
// Re-clamp on window resize (only when using left/top positioning)
|
||||
useEffect(() => {
|
||||
if (!savedPosition) return
|
||||
const onResize = () => {
|
||||
if (!widgetRef.current || draggingRef.current) return
|
||||
const rect = widgetRef.current.getBoundingClientRect()
|
||||
const clamped = clampToViewport(rect.left, rect.top)
|
||||
widgetRef.current.style.left = `${clamped.x}px`
|
||||
widgetRef.current.style.top = `${clamped.y}px`
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [savedPosition, clampToViewport])
|
||||
|
||||
/* ── Filtering ─────────────────────────────────────────────── */
|
||||
|
||||
const filteredLines = useMemo(() => {
|
||||
@@ -252,11 +341,13 @@ export function LiveStreamWidget() {
|
||||
if (state === 'collapsed') {
|
||||
return (
|
||||
<div
|
||||
ref={widgetRef}
|
||||
className="flex items-center"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
...(savedPosition
|
||||
? { left: savedPosition.x, top: savedPosition.y }
|
||||
: { bottom: 20, right: 20 }),
|
||||
zIndex: 50,
|
||||
padding: '6px 6px 6px 14px',
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
@@ -268,6 +359,12 @@ export function LiveStreamWidget() {
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.25)',
|
||||
}}
|
||||
>
|
||||
<GripHorizontal
|
||||
size={14}
|
||||
style={{ color: 'var(--text-quaternary)', cursor: draggingRef.current ? 'grabbing' : 'grab', flexShrink: 0 }}
|
||||
onMouseDown={e => { e.preventDefault(); handleDragStart(e.clientX, e.clientY) }}
|
||||
onTouchStart={e => handleDragStart(e.touches[0].clientX, e.touches[0].clientY)}
|
||||
/>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: streaming ? 'var(--system-green)' : 'var(--text-tertiary)',
|
||||
@@ -316,10 +413,11 @@ export function LiveStreamWidget() {
|
||||
/* ── Expanded panel ───────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
<div ref={widgetRef} style={{
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
right: 20,
|
||||
...(savedPosition
|
||||
? { left: savedPosition.x, top: savedPosition.y }
|
||||
: { bottom: 20, right: 20 }),
|
||||
zIndex: 50,
|
||||
width: 440,
|
||||
height: 440,
|
||||
@@ -333,12 +431,19 @@ export function LiveStreamWidget() {
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* ── Header ────────────────────────────────────────────── */}
|
||||
<div className="flex items-center flex-shrink-0" style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--separator)',
|
||||
gap: 8,
|
||||
}}>
|
||||
{/* ── Header (drag handle) ───────────────────────────────── */}
|
||||
<div
|
||||
className="flex items-center flex-shrink-0"
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid var(--separator)',
|
||||
gap: 8,
|
||||
cursor: draggingRef.current ? 'grabbing' : 'grab',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseDown={e => { e.preventDefault(); handleDragStart(e.clientX, e.clientY) }}
|
||||
onTouchStart={e => handleDragStart(e.touches[0].clientX, e.touches[0].clientY)}
|
||||
>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%',
|
||||
background: streaming ? 'var(--system-green)' : 'var(--text-tertiary)',
|
||||
|
||||
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -35,6 +35,7 @@ describe('loadSettings', () => {
|
||||
emojiOnly: false,
|
||||
operatorName: null,
|
||||
agentOverrides: {},
|
||||
liveStreamPosition: null,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -90,6 +91,7 @@ describe('saveSettings', () => {
|
||||
emojiOnly: false,
|
||||
operatorName: null,
|
||||
agentOverrides: {},
|
||||
liveStreamPosition: null,
|
||||
}
|
||||
saveSettings(settings)
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
@@ -111,6 +113,7 @@ describe('saveSettings', () => {
|
||||
agentOverrides: {
|
||||
vera: { emoji: '🧙', profileImage: 'data:image/jpeg;base64,abc' },
|
||||
},
|
||||
liveStreamPosition: null,
|
||||
}
|
||||
saveSettings(settings)
|
||||
const loaded = loadSettings()
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ClawPortSettings {
|
||||
emojiOnly: boolean // show emoji avatars without colored background
|
||||
operatorName: string | null
|
||||
agentOverrides: Record<string, AgentOverride>
|
||||
liveStreamPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'clawport-settings'
|
||||
@@ -30,6 +31,7 @@ export const DEFAULTS: ClawPortSettings = {
|
||||
emojiOnly: false,
|
||||
operatorName: null,
|
||||
agentOverrides: {},
|
||||
liveStreamPosition: null,
|
||||
}
|
||||
|
||||
export function loadSettings(): ClawPortSettings {
|
||||
@@ -59,6 +61,13 @@ export function loadSettings(): ClawPortSettings {
|
||||
parsed.agentOverrides && typeof parsed.agentOverrides === 'object'
|
||||
? parsed.agentOverrides
|
||||
: {},
|
||||
liveStreamPosition:
|
||||
parsed.liveStreamPosition &&
|
||||
typeof parsed.liveStreamPosition === 'object' &&
|
||||
typeof parsed.liveStreamPosition.x === 'number' &&
|
||||
typeof parsed.liveStreamPosition.y === 'number'
|
||||
? { x: parsed.liveStreamPosition.x, y: parsed.liveStreamPosition.y }
|
||||
: null,
|
||||
}
|
||||
} catch {
|
||||
return { ...DEFAULTS }
|
||||
|
||||