@houston-ai/events 0.3.7

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 ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@houston-ai/events",
3
+ "version": "0.3.7",
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
+ }