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>
This commit is contained in:
JohnRiceML
2026-03-10 13:46:07 -05:00
co-authored by Claude Opus 4.6
parent 3e3a7109ae
commit 15800979c5
14 changed files with 159 additions and 13 deletions
+18
View File
@@ -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
+12 -1
View File
@@ -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,
}}
>
+117 -12
View File
@@ -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)',
Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

+3
View File
@@ -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()
+9
View File
@@ -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 }