@houston-ai/events 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +22 -0
- package/src/event-empty.tsx +23 -0
- package/src/event-feed.tsx +120 -0
- package/src/event-filter.tsx +64 -0
- package/src/event-item.tsx +99 -0
- package/src/index.ts +21 -0
- package/src/types.ts +47 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@houston-ai/events",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"react": "^19.0.0",
|
|
12
|
+
"react-dom": "^19.0.0",
|
|
13
|
+
"@houston-ai/core": "workspace:*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"lucide-react": "^0.577.0",
|
|
17
|
+
"framer-motion": "^12.38.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Empty,
|
|
3
|
+
EmptyHeader,
|
|
4
|
+
EmptyTitle,
|
|
5
|
+
EmptyDescription,
|
|
6
|
+
} from "@houston-ai/core"
|
|
7
|
+
|
|
8
|
+
export interface EventEmptyProps {
|
|
9
|
+
message?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function EventEmpty({
|
|
13
|
+
message = "Heartbeats, cron jobs, and channel messages will appear here as they happen.",
|
|
14
|
+
}: EventEmptyProps) {
|
|
15
|
+
return (
|
|
16
|
+
<Empty className="border-0">
|
|
17
|
+
<EmptyHeader>
|
|
18
|
+
<EmptyTitle>No events</EmptyTitle>
|
|
19
|
+
<EmptyDescription>{message}</EmptyDescription>
|
|
20
|
+
</EmptyHeader>
|
|
21
|
+
</Empty>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { useRef, useEffect, useCallback } from "react"
|
|
2
|
+
import { AnimatePresence, motion } from "framer-motion"
|
|
3
|
+
import { EventItem } from "./event-item"
|
|
4
|
+
import { EventFilter } from "./event-filter"
|
|
5
|
+
import { EventEmpty } from "./event-empty"
|
|
6
|
+
import type { EventEntry, EventType } from "./types"
|
|
7
|
+
|
|
8
|
+
export interface EventFeedProps {
|
|
9
|
+
events: EventEntry[]
|
|
10
|
+
loading?: boolean
|
|
11
|
+
filter?: EventType | null
|
|
12
|
+
onFilterChange?: (type: EventType | null) => void
|
|
13
|
+
onEventClick?: (event: EventEntry) => void
|
|
14
|
+
maxHeight?: string
|
|
15
|
+
emptyMessage?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function EventFeed({
|
|
19
|
+
events,
|
|
20
|
+
loading = false,
|
|
21
|
+
filter = null,
|
|
22
|
+
onFilterChange,
|
|
23
|
+
onEventClick,
|
|
24
|
+
maxHeight = "100%",
|
|
25
|
+
emptyMessage,
|
|
26
|
+
}: EventFeedProps) {
|
|
27
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
28
|
+
const isAtBottomRef = useRef(true)
|
|
29
|
+
|
|
30
|
+
const checkIsAtBottom = useCallback(() => {
|
|
31
|
+
const el = scrollRef.current
|
|
32
|
+
if (!el) return true
|
|
33
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight < 32
|
|
34
|
+
}, [])
|
|
35
|
+
|
|
36
|
+
const scrollToBottom = useCallback(() => {
|
|
37
|
+
const el = scrollRef.current
|
|
38
|
+
if (!el) return
|
|
39
|
+
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" })
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
42
|
+
// Track scroll position
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
const el = scrollRef.current
|
|
45
|
+
if (!el) return
|
|
46
|
+
|
|
47
|
+
const handleScroll = () => {
|
|
48
|
+
isAtBottomRef.current = checkIsAtBottom()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
el.addEventListener("scroll", handleScroll, { passive: true })
|
|
52
|
+
return () => el.removeEventListener("scroll", handleScroll)
|
|
53
|
+
}, [checkIsAtBottom])
|
|
54
|
+
|
|
55
|
+
// Auto-scroll when new events arrive (only if already at bottom)
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (isAtBottomRef.current) {
|
|
58
|
+
scrollToBottom()
|
|
59
|
+
}
|
|
60
|
+
}, [events.length, scrollToBottom])
|
|
61
|
+
|
|
62
|
+
const filteredEvents = filter
|
|
63
|
+
? events.filter((e) => e.type === filter)
|
|
64
|
+
: events
|
|
65
|
+
|
|
66
|
+
const counts = events.reduce<Partial<Record<EventType, number>>>(
|
|
67
|
+
(acc, e) => {
|
|
68
|
+
acc[e.type] = (acc[e.type] ?? 0) + 1
|
|
69
|
+
return acc
|
|
70
|
+
},
|
|
71
|
+
{},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-col flex-1" style={{ maxHeight }}>
|
|
76
|
+
{/* Filter bar */}
|
|
77
|
+
{onFilterChange && (
|
|
78
|
+
<div className="shrink-0 border-b border-border">
|
|
79
|
+
<EventFilter
|
|
80
|
+
value={filter}
|
|
81
|
+
onChange={onFilterChange}
|
|
82
|
+
counts={counts}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
|
|
87
|
+
{/* Event list */}
|
|
88
|
+
<div ref={scrollRef} className="flex-1 flex flex-col overflow-y-auto min-h-0">
|
|
89
|
+
{filteredEvents.length === 0 && !loading ? (
|
|
90
|
+
<EventEmpty message={emptyMessage} />
|
|
91
|
+
) : (
|
|
92
|
+
<div className="divide-y divide-border">
|
|
93
|
+
<AnimatePresence initial={false}>
|
|
94
|
+
{filteredEvents.map((event) => (
|
|
95
|
+
<motion.div
|
|
96
|
+
key={event.id}
|
|
97
|
+
initial={{ opacity: 0, y: 8 }}
|
|
98
|
+
animate={{ opacity: 1, y: 0 }}
|
|
99
|
+
exit={{ opacity: 0, y: -8 }}
|
|
100
|
+
transition={{
|
|
101
|
+
duration: 0.2,
|
|
102
|
+
ease: [0.25, 0.1, 0.25, 1],
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<EventItem event={event} onClick={onEventClick} />
|
|
106
|
+
</motion.div>
|
|
107
|
+
))}
|
|
108
|
+
</AnimatePresence>
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{loading && (
|
|
113
|
+
<div className="flex items-center justify-center py-4">
|
|
114
|
+
<span className="text-xs text-muted-foreground">Loading...</span>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { cn, Badge } from "@houston-ai/core"
|
|
2
|
+
import type { EventType } from "./types"
|
|
3
|
+
import { EVENT_TYPE_LABELS } from "./types"
|
|
4
|
+
|
|
5
|
+
export interface EventFilterProps {
|
|
6
|
+
value: EventType | null
|
|
7
|
+
onChange: (type: EventType | null) => void
|
|
8
|
+
counts?: Partial<Record<EventType, number>>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const EVENT_TYPES: EventType[] = [
|
|
12
|
+
"message",
|
|
13
|
+
"heartbeat",
|
|
14
|
+
"cron",
|
|
15
|
+
"hook",
|
|
16
|
+
"webhook",
|
|
17
|
+
"agent_message",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export function EventFilter({ value, onChange, counts }: EventFilterProps) {
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex items-center gap-1.5 px-3 py-2 overflow-x-auto">
|
|
23
|
+
<button
|
|
24
|
+
onClick={() => onChange(null)}
|
|
25
|
+
className={cn(
|
|
26
|
+
"shrink-0 px-2.5 py-1 rounded-full text-xs font-medium transition-colors duration-150",
|
|
27
|
+
value === null
|
|
28
|
+
? "bg-primary text-primary-foreground"
|
|
29
|
+
: "bg-secondary text-muted-foreground hover:bg-accent",
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
All
|
|
33
|
+
</button>
|
|
34
|
+
|
|
35
|
+
{EVENT_TYPES.map((type) => {
|
|
36
|
+
const count = counts?.[type]
|
|
37
|
+
const isActive = value === type
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
key={type}
|
|
42
|
+
onClick={() => onChange(isActive ? null : type)}
|
|
43
|
+
className={cn(
|
|
44
|
+
"shrink-0 flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium transition-colors duration-150",
|
|
45
|
+
isActive
|
|
46
|
+
? "bg-primary text-primary-foreground"
|
|
47
|
+
: "bg-secondary text-muted-foreground hover:bg-accent",
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{EVENT_TYPE_LABELS[type]}
|
|
51
|
+
{count !== undefined && count > 0 && (
|
|
52
|
+
<Badge
|
|
53
|
+
variant={isActive ? "secondary" : "outline"}
|
|
54
|
+
className="ml-0.5 px-1.5 py-0 text-[10px] leading-4 h-4"
|
|
55
|
+
>
|
|
56
|
+
{count}
|
|
57
|
+
</Badge>
|
|
58
|
+
)}
|
|
59
|
+
</button>
|
|
60
|
+
)
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { cn } from "@houston-ai/core"
|
|
2
|
+
import {
|
|
3
|
+
MessageSquare,
|
|
4
|
+
Heart,
|
|
5
|
+
Clock,
|
|
6
|
+
Zap,
|
|
7
|
+
Globe,
|
|
8
|
+
Bot,
|
|
9
|
+
Check,
|
|
10
|
+
} from "lucide-react"
|
|
11
|
+
import type { EventEntry, EventType } from "./types"
|
|
12
|
+
|
|
13
|
+
export interface EventItemProps {
|
|
14
|
+
event: EventEntry
|
|
15
|
+
onClick?: (event: EventEntry) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const iconMap: Record<EventType, React.ComponentType<{ className?: string }>> = {
|
|
19
|
+
message: MessageSquare,
|
|
20
|
+
heartbeat: Heart,
|
|
21
|
+
cron: Clock,
|
|
22
|
+
hook: Zap,
|
|
23
|
+
webhook: Globe,
|
|
24
|
+
agent_message: Bot,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function statusIndicator(status: EventEntry["status"]) {
|
|
28
|
+
switch (status) {
|
|
29
|
+
case "pending":
|
|
30
|
+
return <span className="flex size-2 shrink-0 rounded-full bg-muted-foreground/40" />
|
|
31
|
+
case "processing":
|
|
32
|
+
return <span className="flex size-2 shrink-0 rounded-full bg-primary tool-active-dot" />
|
|
33
|
+
case "completed":
|
|
34
|
+
return <Check className="size-3 shrink-0 text-green-600" />
|
|
35
|
+
case "suppressed":
|
|
36
|
+
return <span className="flex size-2 shrink-0 rounded-full bg-muted-foreground/30" />
|
|
37
|
+
case "error":
|
|
38
|
+
return <span className="flex size-2 shrink-0 rounded-full bg-destructive" />
|
|
39
|
+
default:
|
|
40
|
+
return <span className="flex size-2 shrink-0 rounded-full bg-muted-foreground/30" />
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function relativeTime(iso: string): string {
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
const then = new Date(iso).getTime()
|
|
47
|
+
const diffSec = Math.floor((now - then) / 1000)
|
|
48
|
+
|
|
49
|
+
if (diffSec < 60) return "just now"
|
|
50
|
+
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
|
|
51
|
+
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`
|
|
52
|
+
return `${Math.floor(diffSec / 86400)}d ago`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function EventItem({ event, onClick }: EventItemProps) {
|
|
56
|
+
const Icon = iconMap[event.type]
|
|
57
|
+
const isSuppressed = event.status === "suppressed"
|
|
58
|
+
const isSuppressedHeartbeat = isSuppressed && event.type === "heartbeat"
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<button
|
|
62
|
+
onClick={() => onClick?.(event)}
|
|
63
|
+
className={cn(
|
|
64
|
+
"w-full text-left flex items-center gap-3 px-3 py-2 transition-colors duration-150",
|
|
65
|
+
"hover:bg-accent/50",
|
|
66
|
+
isSuppressedHeartbeat && "opacity-50",
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{/* Type icon */}
|
|
70
|
+
<div className="flex items-center justify-center w-4 shrink-0">
|
|
71
|
+
<Icon className="size-4 text-muted-foreground" />
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Summary + source */}
|
|
75
|
+
<div className="flex-1 min-w-0">
|
|
76
|
+
<p
|
|
77
|
+
className={cn(
|
|
78
|
+
"text-sm truncate text-foreground",
|
|
79
|
+
isSuppressed && "line-through text-muted-foreground",
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{event.summary}
|
|
83
|
+
</p>
|
|
84
|
+
<p className="text-[11px] text-muted-foreground truncate">
|
|
85
|
+
{event.source.channel}
|
|
86
|
+
{event.source.identifier && ` ${event.source.identifier}`}
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Timestamp + status */}
|
|
91
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
92
|
+
<span className="text-[11px] text-muted-foreground/60">
|
|
93
|
+
{relativeTime(event.createdAt)}
|
|
94
|
+
</span>
|
|
95
|
+
{statusIndicator(event.status)}
|
|
96
|
+
</div>
|
|
97
|
+
</button>
|
|
98
|
+
)
|
|
99
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type {
|
|
3
|
+
EventType,
|
|
4
|
+
EventStatus,
|
|
5
|
+
EventSource,
|
|
6
|
+
EventEntry,
|
|
7
|
+
} from "./types"
|
|
8
|
+
export { EVENT_TYPE_LABELS, EVENT_TYPE_ICONS } from "./types"
|
|
9
|
+
|
|
10
|
+
// Components
|
|
11
|
+
export { EventFeed } from "./event-feed"
|
|
12
|
+
export type { EventFeedProps } from "./event-feed"
|
|
13
|
+
|
|
14
|
+
export { EventItem } from "./event-item"
|
|
15
|
+
export type { EventItemProps } from "./event-item"
|
|
16
|
+
|
|
17
|
+
export { EventFilter } from "./event-filter"
|
|
18
|
+
export type { EventFilterProps } from "./event-filter"
|
|
19
|
+
|
|
20
|
+
export { EventEmpty } from "./event-empty"
|
|
21
|
+
export type { EventEmptyProps } from "./event-empty"
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Generic event types — no app-specific coupling.
|
|
2
|
+
|
|
3
|
+
export type EventType =
|
|
4
|
+
| "message"
|
|
5
|
+
| "heartbeat"
|
|
6
|
+
| "cron"
|
|
7
|
+
| "hook"
|
|
8
|
+
| "webhook"
|
|
9
|
+
| "agent_message"
|
|
10
|
+
|
|
11
|
+
export type EventStatus = "pending" | "processing" | "completed" | "suppressed" | "error"
|
|
12
|
+
|
|
13
|
+
export interface EventSource {
|
|
14
|
+
channel: string // "slack", "telegram", "desktop", "system", "webhook", "agent"
|
|
15
|
+
identifier: string // channel ID, cron job name, etc.
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EventEntry {
|
|
19
|
+
id: string
|
|
20
|
+
type: EventType
|
|
21
|
+
source: EventSource
|
|
22
|
+
summary: string // human-readable description
|
|
23
|
+
status: EventStatus
|
|
24
|
+
payload?: Record<string, unknown>
|
|
25
|
+
sessionKey?: string
|
|
26
|
+
projectId?: string
|
|
27
|
+
createdAt: string // ISO 8601
|
|
28
|
+
processedAt?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const EVENT_TYPE_LABELS: Record<EventType, string> = {
|
|
32
|
+
message: "Message",
|
|
33
|
+
heartbeat: "Heartbeat",
|
|
34
|
+
cron: "Scheduled",
|
|
35
|
+
hook: "Hook",
|
|
36
|
+
webhook: "Webhook",
|
|
37
|
+
agent_message: "Agent",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const EVENT_TYPE_ICONS: Record<EventType, string> = {
|
|
41
|
+
message: "MessageSquare",
|
|
42
|
+
heartbeat: "Heart",
|
|
43
|
+
cron: "Clock",
|
|
44
|
+
hook: "Zap",
|
|
45
|
+
webhook: "Globe",
|
|
46
|
+
agent_message: "Bot",
|
|
47
|
+
}
|