@houston-ai/routines 0.4.6

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/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @deck-ui/routines
2
+
3
+ Automated routine management. Create, edit, run, and review recurring agent tasks with trigger scheduling and approval controls.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @deck-ui/routines
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```tsx
14
+ import { RoutinesGrid, RoutineDetailPage } from "@deck-ui/routines"
15
+
16
+ <RoutinesGrid
17
+ routines={routines}
18
+ loading={false}
19
+ onRoutineClick={(r) => navigate(`/routines/${r.id}`)}
20
+ onCreateRoutine={() => openCreateDialog()}
21
+ />
22
+ ```
23
+
24
+ ## Exports
25
+
26
+ - `RoutinesGrid` -- card grid of all routines
27
+ - `RoutineCard` -- single routine card with status indicator
28
+ - `RoutineDetailPage` -- full detail with description, triggers, run history
29
+ - `RoutineEditForm` -- create/edit form for routine configuration
30
+ - `RoutineDetailActions` -- action buttons (run, edit, delete)
31
+ - `RoutineRunPage` -- live view of a running routine
32
+ - `RunHistory` -- past run list with status and duration
33
+ - Types: `Routine`, `RoutineRun`, `RoutineFormState`, `TriggerType`, `RoutineStatus`, `ApprovalMode`, `RunStatus`
34
+
35
+ ## Peer Dependencies
36
+
37
+ - React 19+
38
+ - @deck-ui/core
39
+
40
+ ---
41
+
42
+ Part of [Keel & Deck](../../README.md).
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@houston-ai/routines",
3
+ "version": "0.4.6",
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,184 @@
1
+ /**
2
+ * HeartbeatConfig — Form for configuring agent heartbeat check-ins.
3
+ * Toggle, interval, prompt, active hours, and suppression token.
4
+ */
5
+ import { useState } from "react"
6
+ import { ChevronDown, ChevronRight } from "lucide-react"
7
+ import { cn } from "@houston-ai/core"
8
+ import type { HeartbeatConfig as HeartbeatConfigType } from "./types"
9
+
10
+ export interface HeartbeatConfigProps {
11
+ config: HeartbeatConfigType
12
+ onChange: (config: HeartbeatConfigType) => void
13
+ loading?: boolean
14
+ }
15
+
16
+ const inputClass = cn(
17
+ "w-full px-3 py-2 rounded-xl border border-border bg-background",
18
+ "text-sm text-foreground placeholder:text-muted-foreground/60",
19
+ "focus:outline-none focus:border-border/80 transition-colors",
20
+ )
21
+
22
+ const labelClass = "text-xs font-medium text-muted-foreground mb-1.5 block"
23
+
24
+ const INTERVAL_OPTIONS = [
25
+ { value: 15, label: "Every 15 minutes" },
26
+ { value: 30, label: "Every 30 minutes" },
27
+ { value: 60, label: "Every hour" },
28
+ { value: 120, label: "Every 2 hours" },
29
+ { value: 240, label: "Every 4 hours" },
30
+ ]
31
+
32
+ export function HeartbeatConfig({
33
+ config,
34
+ onChange,
35
+ loading,
36
+ }: HeartbeatConfigProps) {
37
+ const [showActiveHours, setShowActiveHours] = useState(
38
+ Boolean(config.activeHoursStart || config.activeHoursEnd),
39
+ )
40
+ const [showAdvanced, setShowAdvanced] = useState(false)
41
+
42
+ const update = (patch: Partial<HeartbeatConfigType>) => {
43
+ onChange({ ...config, ...patch })
44
+ }
45
+
46
+ return (
47
+ <div className={cn("space-y-5", loading && "opacity-60 pointer-events-none")}>
48
+ {/* Enable toggle */}
49
+ <div className="flex items-center justify-between">
50
+ <div>
51
+ <p className="text-sm font-medium text-foreground">Heartbeat</p>
52
+ <p className="text-[11px] text-muted-foreground mt-0.5">
53
+ The agent will check in at this interval. If nothing needs
54
+ attention, the check-in is silently suppressed.
55
+ </p>
56
+ </div>
57
+ <button
58
+ onClick={() => update({ enabled: !config.enabled })}
59
+ className={cn(
60
+ "relative inline-flex h-5 w-9 shrink-0 rounded-full transition-colors",
61
+ "border-2 border-transparent cursor-pointer",
62
+ config.enabled ? "bg-primary" : "bg-muted-foreground/20",
63
+ )}
64
+ >
65
+ <span
66
+ className={cn(
67
+ "pointer-events-none inline-block size-4 rounded-full bg-white shadow-sm",
68
+ "transition-transform",
69
+ config.enabled ? "translate-x-4" : "translate-x-0",
70
+ )}
71
+ />
72
+ </button>
73
+ </div>
74
+
75
+ {config.enabled && (
76
+ <>
77
+ {/* Interval */}
78
+ <div>
79
+ <label className={labelClass}>Check-in Interval</label>
80
+ <select
81
+ value={config.intervalMinutes}
82
+ onChange={(e) => update({ intervalMinutes: Number(e.target.value) })}
83
+ className={inputClass}
84
+ >
85
+ {INTERVAL_OPTIONS.map((opt) => (
86
+ <option key={opt.value} value={opt.value}>
87
+ {opt.label}
88
+ </option>
89
+ ))}
90
+ </select>
91
+ </div>
92
+
93
+ {/* Prompt */}
94
+ <div>
95
+ <label className={labelClass}>Heartbeat Prompt</label>
96
+ <textarea
97
+ value={config.prompt}
98
+ onChange={(e) => update({ prompt: e.target.value })}
99
+ placeholder="Check if there's anything that needs attention..."
100
+ rows={3}
101
+ className={cn(inputClass, "resize-none")}
102
+ />
103
+ </div>
104
+
105
+ {/* Active hours (collapsible) */}
106
+ <CollapsibleSection
107
+ label="Active Hours"
108
+ open={showActiveHours}
109
+ onToggle={() => setShowActiveHours((v) => !v)}
110
+ >
111
+ <div className="grid grid-cols-2 gap-3">
112
+ <div>
113
+ <label className={labelClass}>Start</label>
114
+ <input
115
+ type="time"
116
+ value={config.activeHoursStart ?? "09:00"}
117
+ onChange={(e) => update({ activeHoursStart: e.target.value })}
118
+ className={inputClass}
119
+ />
120
+ </div>
121
+ <div>
122
+ <label className={labelClass}>End</label>
123
+ <input
124
+ type="time"
125
+ value={config.activeHoursEnd ?? "22:00"}
126
+ onChange={(e) => update({ activeHoursEnd: e.target.value })}
127
+ className={inputClass}
128
+ />
129
+ </div>
130
+ </div>
131
+ </CollapsibleSection>
132
+
133
+ {/* Advanced (collapsible) */}
134
+ <CollapsibleSection
135
+ label="Advanced"
136
+ open={showAdvanced}
137
+ onToggle={() => setShowAdvanced((v) => !v)}
138
+ >
139
+ <div>
140
+ <label className={labelClass}>Suppression Token</label>
141
+ <input
142
+ type="text"
143
+ value={config.suppressionToken}
144
+ onChange={(e) => update({ suppressionToken: e.target.value })}
145
+ placeholder="heartbeat_ok"
146
+ className={inputClass}
147
+ />
148
+ <p className="text-[11px] text-muted-foreground mt-1">
149
+ When the agent responds with this token, the check-in
150
+ is silently suppressed.
151
+ </p>
152
+ </div>
153
+ </CollapsibleSection>
154
+ </>
155
+ )}
156
+ </div>
157
+ )
158
+ }
159
+
160
+ function CollapsibleSection({
161
+ label,
162
+ open,
163
+ onToggle,
164
+ children,
165
+ }: {
166
+ label: string
167
+ open: boolean
168
+ onToggle: () => void
169
+ children: React.ReactNode
170
+ }) {
171
+ const Arrow = open ? ChevronDown : ChevronRight
172
+ return (
173
+ <div>
174
+ <button
175
+ onClick={onToggle}
176
+ className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors mb-2"
177
+ >
178
+ <Arrow className="size-3" />
179
+ {label}
180
+ </button>
181
+ {open && <div className="pl-4.5">{children}</div>}
182
+ </div>
183
+ )
184
+ }
package/src/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Types
2
+ export type {
3
+ Routine,
4
+ RoutineRun,
5
+ RoutineFormState,
6
+ Skill,
7
+ TriggerType,
8
+ RoutineStatus,
9
+ ApprovalMode,
10
+ RunStatus,
11
+ HeartbeatConfig,
12
+ SchedulePreset,
13
+ } from "./types"
14
+ export { TRIGGER_LABELS, SCHEDULE_PRESET_LABELS } from "./types"
15
+
16
+ // Components
17
+ export { RoutinesGrid } from "./routines-grid"
18
+ export type { RoutinesGridProps } from "./routines-grid"
19
+
20
+ export { RoutineCard } from "./routine-card"
21
+ export type { RoutineCardProps } from "./routine-card"
22
+
23
+ export { RoutineDetailPage } from "./routine-detail-page"
24
+ export type { RoutineDetailPageProps } from "./routine-detail-page"
25
+
26
+ export { RoutineEditForm } from "./routine-edit-form"
27
+ export type { RoutineEditFormProps } from "./routine-edit-form"
28
+
29
+ export { RoutineDetailActions } from "./routine-detail-actions"
30
+ export type { RoutineDetailActionsProps } from "./routine-detail-actions"
31
+
32
+ export { RoutineRunPage } from "./routine-run-page"
33
+ export type { RoutineRunPageProps } from "./routine-run-page"
34
+
35
+ export { RunHistory } from "./routine-run-history"
36
+ export type { RunHistoryProps } from "./routine-run-history"
37
+
38
+ export { HeartbeatConfig as HeartbeatConfigPanel } from "./heartbeat-config"
39
+ export type { HeartbeatConfigProps } from "./heartbeat-config"
40
+
41
+ export { ScheduleBuilder } from "./schedule-builder"
42
+ export type { ScheduleBuilderProps } from "./schedule-builder"
@@ -0,0 +1,87 @@
1
+ import type { Routine } from "./types"
2
+ import { TRIGGER_LABELS } from "./types"
3
+ import { cn } from "@houston-ai/core"
4
+ import { Clock } from "lucide-react"
5
+
6
+ export interface RoutineCardProps {
7
+ routine: Routine
8
+ onClick: () => void
9
+ }
10
+
11
+ const STATUS_DOT: Record<string, string> = {
12
+ active: "bg-success",
13
+ paused: "bg-muted-foreground",
14
+ needs_setup: "bg-yellow-500",
15
+ error: "bg-destructive",
16
+ }
17
+
18
+ const STATUS_LABEL: Record<string, string> = {
19
+ active: "Active",
20
+ paused: "Paused",
21
+ needs_setup: "Needs Setup",
22
+ error: "Error",
23
+ }
24
+
25
+ export function RoutineCard({ routine, onClick }: RoutineCardProps) {
26
+ const displayName = routine.name || "Untitled"
27
+ const triggerLabel =
28
+ TRIGGER_LABELS[routine.trigger_type as keyof typeof TRIGGER_LABELS] ??
29
+ routine.trigger_type
30
+ const lastRun = routine.last_run_at
31
+ ? new Date(routine.last_run_at).toLocaleDateString("en-US", {
32
+ month: "short",
33
+ day: "numeric",
34
+ })
35
+ : null
36
+
37
+ return (
38
+ <button
39
+ onClick={onClick}
40
+ className={cn(
41
+ "w-full text-left rounded-2xl border border-border p-5",
42
+ "bg-background hover:border-border/80 transition-all duration-150",
43
+ "hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]",
44
+ "flex flex-col gap-3",
45
+ )}
46
+ >
47
+ {/* Top: name + status */}
48
+ <div className="flex items-start gap-2.5">
49
+ <h3 className="text-[15px] font-medium text-foreground leading-snug flex-1 min-w-0">
50
+ {displayName}
51
+ </h3>
52
+ <div className="flex items-center gap-1.5 shrink-0 mt-0.5">
53
+ <div
54
+ className={cn(
55
+ "size-1.5 rounded-full",
56
+ STATUS_DOT[routine.status] ?? "bg-muted-foreground",
57
+ )}
58
+ />
59
+ <span className="text-xs text-muted-foreground">
60
+ {STATUS_LABEL[routine.status] ?? routine.status}
61
+ </span>
62
+ </div>
63
+ </div>
64
+
65
+ {/* Description */}
66
+ {routine.description && (
67
+ <p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
68
+ {routine.description}
69
+ </p>
70
+ )}
71
+
72
+ {/* Footer: trigger + last run + run count */}
73
+ <div className="flex items-center gap-3 text-xs text-muted-foreground pt-1">
74
+ <span className="flex items-center gap-1">
75
+ <Clock className="size-3" />
76
+ {triggerLabel}
77
+ </span>
78
+ {routine.run_count > 0 && (
79
+ <span>
80
+ {routine.run_count} run{routine.run_count !== 1 ? "s" : ""}
81
+ </span>
82
+ )}
83
+ {lastRun && <span>Last: {lastRun}</span>}
84
+ </div>
85
+ </button>
86
+ )
87
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * RoutineDetailActions — Action buttons for the routine detail page.
3
+ * Save, Run Now, Pause/Resume, Delete.
4
+ */
5
+ import { useState } from "react"
6
+ import { Pause, Play, RefreshCw, Save, Trash2 } from "lucide-react"
7
+ import { cn } from "@houston-ai/core"
8
+
9
+ export interface RoutineDetailActionsProps {
10
+ isActive: boolean
11
+ saving: boolean
12
+ onSave: () => void
13
+ onToggle: () => void
14
+ onRunNow: () => void
15
+ onDelete: () => void
16
+ }
17
+
18
+ export function RoutineDetailActions({
19
+ isActive,
20
+ saving,
21
+ onSave,
22
+ onToggle,
23
+ onRunNow,
24
+ onDelete,
25
+ }: RoutineDetailActionsProps) {
26
+ const [confirmDelete, setConfirmDelete] = useState(false)
27
+
28
+ const handleDelete = () => {
29
+ if (!confirmDelete) {
30
+ setConfirmDelete(true)
31
+ return
32
+ }
33
+ onDelete()
34
+ }
35
+
36
+ const pillBtn = cn(
37
+ "h-9 px-4 text-sm font-medium rounded-full transition-colors",
38
+ "border border-border",
39
+ )
40
+
41
+ return (
42
+ <div className="flex items-center gap-2 pt-4 border-t border-border">
43
+ <button
44
+ onClick={onSave}
45
+ disabled={saving}
46
+ className={cn(
47
+ "h-9 px-4 text-sm font-medium rounded-full",
48
+ "bg-primary text-primary-foreground hover:bg-primary/90 transition-colors",
49
+ "disabled:opacity-50",
50
+ )}
51
+ >
52
+ <span className="flex items-center gap-1.5">
53
+ <Save className="size-3.5" />
54
+ {saving ? "Saving..." : "Save"}
55
+ </span>
56
+ </button>
57
+
58
+ <button onClick={onRunNow} className={cn(pillBtn, "text-foreground hover:bg-secondary")}>
59
+ <span className="flex items-center gap-1.5">
60
+ <RefreshCw className="size-3.5" /> Run Now
61
+ </span>
62
+ </button>
63
+
64
+ <button onClick={onToggle} className={cn(pillBtn, "text-foreground hover:bg-secondary")}>
65
+ <span className="flex items-center gap-1.5">
66
+ {isActive ? (
67
+ <><Pause className="size-3.5" /> Pause</>
68
+ ) : (
69
+ <><Play className="size-3.5" /> Resume</>
70
+ )}
71
+ </span>
72
+ </button>
73
+
74
+ <div className="flex-1" />
75
+
76
+ {confirmDelete ? (
77
+ <div className="flex items-center gap-2">
78
+ <span className="text-xs text-destructive">Delete?</span>
79
+ <button
80
+ onClick={handleDelete}
81
+ className="h-9 px-3 text-sm font-medium rounded-full bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
82
+ >
83
+ Confirm
84
+ </button>
85
+ <button
86
+ onClick={() => setConfirmDelete(false)}
87
+ className={cn(pillBtn, "text-foreground hover:bg-secondary")}
88
+ >
89
+ Cancel
90
+ </button>
91
+ </div>
92
+ ) : (
93
+ <button
94
+ onClick={handleDelete}
95
+ className={cn(pillBtn, "text-muted-foreground hover:text-destructive hover:border-destructive/20")}
96
+ >
97
+ <span className="flex items-center gap-1.5">
98
+ <Trash2 className="size-3.5" /> Delete
99
+ </span>
100
+ </button>
101
+ )}
102
+ </div>
103
+ )
104
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * RoutineDetailPage — Full-page detail view for a single routine.
3
+ * Shows editable fields, action buttons, and run history below.
4
+ * All data and actions provided via props (no Zustand, no Tauri).
5
+ */
6
+ import { useCallback, useEffect, useState } from "react"
7
+ import type { Routine, RoutineRun, Skill } from "./types"
8
+ import type { RoutineFormState } from "./routine-edit-form"
9
+ import { RoutineEditForm } from "./routine-edit-form"
10
+ import { RoutineDetailActions } from "./routine-detail-actions"
11
+ import { RunHistory } from "./routine-run-history"
12
+ import { ArrowLeft } from "lucide-react"
13
+
14
+ export interface RoutineDetailPageProps {
15
+ routine: Routine | undefined
16
+ runs: RoutineRun[]
17
+ skills: Skill[]
18
+ onBack: () => void
19
+ onSave: (form: RoutineFormState) => Promise<void>
20
+ onRunNow: () => Promise<void>
21
+ onToggle: () => Promise<void>
22
+ onDelete: () => Promise<void>
23
+ onSelectRun: (runId: string) => void
24
+ }
25
+
26
+ export function RoutineDetailPage({
27
+ routine,
28
+ runs,
29
+ skills,
30
+ onBack,
31
+ onSave,
32
+ onRunNow,
33
+ onToggle,
34
+ onDelete,
35
+ onSelectRun,
36
+ }: RoutineDetailPageProps) {
37
+ const [saving, setSaving] = useState(false)
38
+ const [form, setForm] = useState<RoutineFormState | null>(null)
39
+
40
+ // Initialize form from routine
41
+ useEffect(() => {
42
+ if (routine) {
43
+ setForm({
44
+ name: routine.name,
45
+ description: routine.description,
46
+ context: routine.context,
47
+ triggerType: routine.trigger_type as RoutineFormState["triggerType"],
48
+ approvalMode: routine.approval_mode as RoutineFormState["approvalMode"],
49
+ skillId: routine.skill_id,
50
+ })
51
+ }
52
+ }, [routine?.id]) // Only reset on routine change, not every re-render
53
+
54
+ const handleChange = useCallback((patch: Partial<RoutineFormState>) => {
55
+ setForm((prev) => (prev ? { ...prev, ...patch } : null))
56
+ }, [])
57
+
58
+ const handleSave = useCallback(async () => {
59
+ if (!form) return
60
+ setSaving(true)
61
+ try {
62
+ await onSave(form)
63
+ } finally {
64
+ setSaving(false)
65
+ }
66
+ }, [form, onSave])
67
+
68
+ if (!routine || !form) {
69
+ return (
70
+ <div className="flex-1 flex items-center justify-center">
71
+ <p className="text-sm text-muted-foreground">Routine not found</p>
72
+ </div>
73
+ )
74
+ }
75
+
76
+ return (
77
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
78
+ {/* Header */}
79
+ <div className="shrink-0 px-6 py-3 border-b border-border">
80
+ <div className="max-w-2xl mx-auto flex items-center gap-3">
81
+ <button
82
+ onClick={onBack}
83
+ className="size-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
84
+ >
85
+ <ArrowLeft className="size-4" />
86
+ </button>
87
+ <h1 className="text-sm font-medium text-foreground truncate flex-1">
88
+ {form.name || "Untitled"}
89
+ </h1>
90
+ </div>
91
+ </div>
92
+
93
+ {/* Scrollable body */}
94
+ <div className="flex-1 overflow-y-auto px-6 py-6">
95
+ <div className="max-w-2xl mx-auto space-y-8">
96
+ <RoutineEditForm form={form} skills={skills} onChange={handleChange} />
97
+
98
+ <RoutineDetailActions
99
+ isActive={routine.status === "active"}
100
+ saving={saving}
101
+ onSave={handleSave}
102
+ onToggle={onToggle}
103
+ onRunNow={onRunNow}
104
+ onDelete={onDelete}
105
+ />
106
+
107
+ <RunHistory runs={runs} onSelectRun={onSelectRun} />
108
+ </div>
109
+ </div>
110
+ </div>
111
+ )
112
+ }