@deck-ui/routines 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 +20 -0
- package/src/index.ts +34 -0
- package/src/routine-card.tsx +87 -0
- package/src/routine-detail-actions.tsx +104 -0
- package/src/routine-detail-page.tsx +112 -0
- package/src/routine-edit-form.tsx +135 -0
- package/src/routine-run-history.tsx +87 -0
- package/src/routine-run-page.tsx +95 -0
- package/src/routines-grid.tsx +79 -0
- package/src/styles.css +1 -0
- package/src/types.ts +80 -0
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deck-ui/routines",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"files": ["src"],
|
|
8
|
+
"peerDependencies": {
|
|
9
|
+
"react": "^19.0.0",
|
|
10
|
+
"react-dom": "^19.0.0",
|
|
11
|
+
"@deck-ui/core": "^0.1.0"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"lucide-react": "^0.577.0",
|
|
15
|
+
"framer-motion": "^12.38.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "tsc --noEmit"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type {
|
|
3
|
+
Routine,
|
|
4
|
+
RoutineRun,
|
|
5
|
+
RoutineFormState,
|
|
6
|
+
Skill,
|
|
7
|
+
TriggerType,
|
|
8
|
+
RoutineStatus,
|
|
9
|
+
ApprovalMode,
|
|
10
|
+
RunStatus,
|
|
11
|
+
} from "./types"
|
|
12
|
+
export { TRIGGER_LABELS } from "./types"
|
|
13
|
+
|
|
14
|
+
// Components
|
|
15
|
+
export { RoutinesGrid } from "./routines-grid"
|
|
16
|
+
export type { RoutinesGridProps } from "./routines-grid"
|
|
17
|
+
|
|
18
|
+
export { RoutineCard } from "./routine-card"
|
|
19
|
+
export type { RoutineCardProps } from "./routine-card"
|
|
20
|
+
|
|
21
|
+
export { RoutineDetailPage } from "./routine-detail-page"
|
|
22
|
+
export type { RoutineDetailPageProps } from "./routine-detail-page"
|
|
23
|
+
|
|
24
|
+
export { RoutineEditForm } from "./routine-edit-form"
|
|
25
|
+
export type { RoutineEditFormProps } from "./routine-edit-form"
|
|
26
|
+
|
|
27
|
+
export { RoutineDetailActions } from "./routine-detail-actions"
|
|
28
|
+
export type { RoutineDetailActionsProps } from "./routine-detail-actions"
|
|
29
|
+
|
|
30
|
+
export { RoutineRunPage } from "./routine-run-page"
|
|
31
|
+
export type { RoutineRunPageProps } from "./routine-run-page"
|
|
32
|
+
|
|
33
|
+
export { RunHistory } from "./routine-run-history"
|
|
34
|
+
export type { RunHistoryProps } from "./routine-run-history"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Routine } from "./types"
|
|
2
|
+
import { TRIGGER_LABELS } from "./types"
|
|
3
|
+
import { cn } from "@deck-ui/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-green-500",
|
|
13
|
+
paused: "bg-[#9b9b9b]",
|
|
14
|
+
needs_setup: "bg-yellow-500",
|
|
15
|
+
error: "bg-red-500",
|
|
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 || routine.title || "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-black/[0.08] p-5",
|
|
42
|
+
"bg-white hover:border-black/[0.15] 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-[#0d0d0d] 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-[#9b9b9b]",
|
|
57
|
+
)}
|
|
58
|
+
/>
|
|
59
|
+
<span className="text-xs text-[#9b9b9b]">
|
|
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-[#5d5d5d] 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-[#9b9b9b] 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 "@deck-ui/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-black/[0.1]",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex items-center gap-2 pt-4 border-t border-black/[0.06]">
|
|
43
|
+
<button
|
|
44
|
+
onClick={onSave}
|
|
45
|
+
disabled={saving}
|
|
46
|
+
className={cn(
|
|
47
|
+
"h-9 px-4 text-sm font-medium rounded-full",
|
|
48
|
+
"bg-[#0d0d0d] text-white hover:bg-[#0d0d0d]/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-[#0d0d0d] hover:bg-[#f5f5f5]")}>
|
|
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-[#0d0d0d] hover:bg-[#f5f5f5]")}>
|
|
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-red-600">Delete?</span>
|
|
79
|
+
<button
|
|
80
|
+
onClick={handleDelete}
|
|
81
|
+
className="h-9 px-3 text-sm font-medium rounded-full bg-red-600 text-white hover:bg-red-700 transition-colors"
|
|
82
|
+
>
|
|
83
|
+
Confirm
|
|
84
|
+
</button>
|
|
85
|
+
<button
|
|
86
|
+
onClick={() => setConfirmDelete(false)}
|
|
87
|
+
className={cn(pillBtn, "text-[#0d0d0d] hover:bg-[#f5f5f5]")}
|
|
88
|
+
>
|
|
89
|
+
Cancel
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
) : (
|
|
93
|
+
<button
|
|
94
|
+
onClick={handleDelete}
|
|
95
|
+
className={cn(pillBtn, "text-[#9b9b9b] hover:text-red-600 hover:border-red-200")}
|
|
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 || routine.title || "",
|
|
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-[#9b9b9b]">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-black/[0.06]">
|
|
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-[#9b9b9b] hover:text-[#0d0d0d] hover:bg-black/[0.05] transition-colors"
|
|
84
|
+
>
|
|
85
|
+
<ArrowLeft className="size-4" />
|
|
86
|
+
</button>
|
|
87
|
+
<h1 className="text-sm font-medium text-[#0d0d0d] 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
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoutineEditForm — Editable fields for a routine's details.
|
|
3
|
+
* Pure presentational component: data and callbacks via props.
|
|
4
|
+
*/
|
|
5
|
+
import type { TriggerType, Skill } from "./types"
|
|
6
|
+
import type { RoutineFormState } from "./types"
|
|
7
|
+
import { cn } from "@deck-ui/core"
|
|
8
|
+
|
|
9
|
+
export type { RoutineFormState }
|
|
10
|
+
|
|
11
|
+
export interface RoutineEditFormProps {
|
|
12
|
+
form: RoutineFormState
|
|
13
|
+
skills: Skill[]
|
|
14
|
+
onChange: (patch: Partial<RoutineFormState>) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TRIGGER_OPTIONS: { value: TriggerType; label: string }[] = [
|
|
18
|
+
{ value: "manual", label: "Manual" },
|
|
19
|
+
{ value: "scheduled", label: "Scheduled" },
|
|
20
|
+
{ value: "periodic", label: "Periodic" },
|
|
21
|
+
{ value: "on_approval", label: "On Approval" },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
const inputClass = cn(
|
|
25
|
+
"w-full px-3 py-2 rounded-xl border border-black/[0.08] bg-white",
|
|
26
|
+
"text-sm text-[#0d0d0d] placeholder:text-[#b4b4b4]",
|
|
27
|
+
"focus:outline-none focus:border-black/20 transition-colors",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const labelClass = "text-xs font-medium text-[#8e8e8e] mb-1.5 block"
|
|
31
|
+
|
|
32
|
+
export function RoutineEditForm({
|
|
33
|
+
form,
|
|
34
|
+
skills,
|
|
35
|
+
onChange,
|
|
36
|
+
}: RoutineEditFormProps) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-5">
|
|
39
|
+
{/* Name */}
|
|
40
|
+
<div>
|
|
41
|
+
<label className={labelClass}>Name</label>
|
|
42
|
+
<input
|
|
43
|
+
type="text"
|
|
44
|
+
value={form.name}
|
|
45
|
+
onChange={(e) => onChange({ name: e.target.value })}
|
|
46
|
+
placeholder="Routine name"
|
|
47
|
+
className={inputClass}
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* Description */}
|
|
52
|
+
<div>
|
|
53
|
+
<label className={labelClass}>Description</label>
|
|
54
|
+
<textarea
|
|
55
|
+
value={form.description}
|
|
56
|
+
onChange={(e) => onChange({ description: e.target.value })}
|
|
57
|
+
placeholder="What does this routine do?"
|
|
58
|
+
rows={2}
|
|
59
|
+
className={cn(inputClass, "resize-none")}
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{/* Prompt / Context */}
|
|
64
|
+
<div>
|
|
65
|
+
<label className={labelClass}>Prompt</label>
|
|
66
|
+
<textarea
|
|
67
|
+
value={form.context}
|
|
68
|
+
onChange={(e) => onChange({ context: e.target.value })}
|
|
69
|
+
placeholder="Instructions for the agent when running this routine..."
|
|
70
|
+
rows={4}
|
|
71
|
+
className={cn(inputClass, "resize-none")}
|
|
72
|
+
/>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
{/* Two-column: Trigger + Skill */}
|
|
76
|
+
<div className="grid grid-cols-2 gap-4">
|
|
77
|
+
<div>
|
|
78
|
+
<label className={labelClass}>Trigger</label>
|
|
79
|
+
<select
|
|
80
|
+
value={form.triggerType}
|
|
81
|
+
onChange={(e) =>
|
|
82
|
+
onChange({ triggerType: e.target.value as TriggerType })
|
|
83
|
+
}
|
|
84
|
+
className={inputClass}
|
|
85
|
+
>
|
|
86
|
+
{TRIGGER_OPTIONS.map((opt) => (
|
|
87
|
+
<option key={opt.value} value={opt.value}>
|
|
88
|
+
{opt.label}
|
|
89
|
+
</option>
|
|
90
|
+
))}
|
|
91
|
+
</select>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div>
|
|
95
|
+
<label className={labelClass}>Skill</label>
|
|
96
|
+
<select
|
|
97
|
+
value={form.skillId ?? ""}
|
|
98
|
+
onChange={(e) =>
|
|
99
|
+
onChange({ skillId: e.target.value || null })
|
|
100
|
+
}
|
|
101
|
+
className={inputClass}
|
|
102
|
+
>
|
|
103
|
+
<option value="">None</option>
|
|
104
|
+
{skills.map((s) => (
|
|
105
|
+
<option key={s.id} value={s.id}>
|
|
106
|
+
{s.name}
|
|
107
|
+
</option>
|
|
108
|
+
))}
|
|
109
|
+
</select>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{/* Approval mode */}
|
|
114
|
+
<div>
|
|
115
|
+
<label className={labelClass}>Approval</label>
|
|
116
|
+
<div className="flex gap-2">
|
|
117
|
+
{(["manual", "auto_approve"] as RoutineFormState["approvalMode"][]).map((mode) => (
|
|
118
|
+
<button
|
|
119
|
+
key={mode}
|
|
120
|
+
onClick={() => onChange({ approvalMode: mode })}
|
|
121
|
+
className={cn(
|
|
122
|
+
"h-9 px-4 rounded-full text-sm font-medium transition-colors",
|
|
123
|
+
form.approvalMode === mode
|
|
124
|
+
? "bg-[#0d0d0d] text-white"
|
|
125
|
+
: "border border-black/[0.1] text-[#5d5d5d] hover:bg-[#f5f5f5]",
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
{mode === "manual" ? "Manual Review" : "Auto-approve"}
|
|
129
|
+
</button>
|
|
130
|
+
))}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { RoutineRun } from "./types"
|
|
2
|
+
import {
|
|
3
|
+
Check,
|
|
4
|
+
AlertCircle,
|
|
5
|
+
Clock,
|
|
6
|
+
Loader2,
|
|
7
|
+
} from "lucide-react"
|
|
8
|
+
|
|
9
|
+
export interface RunHistoryProps {
|
|
10
|
+
runs: RoutineRun[]
|
|
11
|
+
onSelectRun: (id: string) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function RunHistory({ runs, onSelectRun }: RunHistoryProps) {
|
|
15
|
+
if (runs.length === 0) {
|
|
16
|
+
return (
|
|
17
|
+
<div>
|
|
18
|
+
<p className="text-xs font-medium text-[#9b9b9b] uppercase tracking-wide mb-2">
|
|
19
|
+
Runs
|
|
20
|
+
</p>
|
|
21
|
+
<p className="text-sm text-[#9b9b9b]">No runs yet.</p>
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sorted = [...runs].sort(
|
|
27
|
+
(a, b) =>
|
|
28
|
+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div>
|
|
33
|
+
<p className="text-xs font-medium text-[#9b9b9b] uppercase tracking-wide mb-2">
|
|
34
|
+
Run History
|
|
35
|
+
</p>
|
|
36
|
+
<div className="space-y-1">
|
|
37
|
+
{sorted.map((run) => (
|
|
38
|
+
<RunRow key={run.id} run={run} onClick={() => onSelectRun(run.id)} />
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function RunRow({ run, onClick }: { run: RoutineRun; onClick: () => void }) {
|
|
46
|
+
const date = new Date(run.created_at)
|
|
47
|
+
const dateStr = date.toLocaleDateString("en-US", {
|
|
48
|
+
month: "short",
|
|
49
|
+
day: "numeric",
|
|
50
|
+
})
|
|
51
|
+
const timeStr = date.toLocaleTimeString("en-US", {
|
|
52
|
+
hour: "numeric",
|
|
53
|
+
minute: "2-digit",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const icon = (() => {
|
|
57
|
+
switch (run.status) {
|
|
58
|
+
case "running":
|
|
59
|
+
return <Loader2 className="size-3.5 text-blue-500 animate-spin" />
|
|
60
|
+
case "needs_you":
|
|
61
|
+
return <Clock className="size-3.5 text-amber-500" />
|
|
62
|
+
case "done":
|
|
63
|
+
case "approved":
|
|
64
|
+
return <Check className="size-3.5 text-emerald-500" />
|
|
65
|
+
case "error":
|
|
66
|
+
case "failed":
|
|
67
|
+
return <AlertCircle className="size-3.5 text-red-500" />
|
|
68
|
+
default:
|
|
69
|
+
return <Clock className="size-3.5 text-[#9b9b9b]" />
|
|
70
|
+
}
|
|
71
|
+
})()
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<button
|
|
75
|
+
onClick={onClick}
|
|
76
|
+
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left hover:bg-black/[0.03] transition-colors"
|
|
77
|
+
>
|
|
78
|
+
{icon}
|
|
79
|
+
<span className="text-sm text-[#0d0d0d] truncate flex-1">
|
|
80
|
+
{run.output_title || `${dateStr} ${timeStr}`}
|
|
81
|
+
</span>
|
|
82
|
+
<span className="text-xs text-[#9b9b9b] shrink-0">
|
|
83
|
+
{dateStr} {timeStr}
|
|
84
|
+
</span>
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoutineRunPage — Full-page view for a single routine run.
|
|
3
|
+
* Shows the agent conversation header and delegates chat rendering
|
|
4
|
+
* to a consumer-provided component via the `renderChat` prop.
|
|
5
|
+
*/
|
|
6
|
+
import type { Routine, RoutineRun } from "./types"
|
|
7
|
+
import { ArrowLeft, Loader2 } from "lucide-react"
|
|
8
|
+
import { cn } from "@deck-ui/core"
|
|
9
|
+
|
|
10
|
+
export interface RoutineRunPageProps {
|
|
11
|
+
routine: Routine | undefined
|
|
12
|
+
run: RoutineRun | undefined
|
|
13
|
+
onBack: () => void
|
|
14
|
+
/** Render the chat feed area. Receives run status context. */
|
|
15
|
+
renderChat: (context: {
|
|
16
|
+
runId: string
|
|
17
|
+
isRunning: boolean
|
|
18
|
+
isNeedsYou: boolean
|
|
19
|
+
}) => React.ReactNode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const RUN_STATUS_LABEL: Record<string, string> = {
|
|
23
|
+
running: "Running",
|
|
24
|
+
needs_you: "Needs You",
|
|
25
|
+
done: "Done",
|
|
26
|
+
approved: "Approved",
|
|
27
|
+
completed: "Completed",
|
|
28
|
+
error: "Error",
|
|
29
|
+
failed: "Failed",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function RoutineRunPage({
|
|
33
|
+
routine,
|
|
34
|
+
run,
|
|
35
|
+
onBack,
|
|
36
|
+
renderChat,
|
|
37
|
+
}: RoutineRunPageProps) {
|
|
38
|
+
const isRunning = run?.status === "running"
|
|
39
|
+
const isNeedsYou = run?.status === "needs_you"
|
|
40
|
+
|
|
41
|
+
const displayName = routine?.name || routine?.title || "Routine"
|
|
42
|
+
const statusLabel = run ? (RUN_STATUS_LABEL[run.status] ?? run.status) : ""
|
|
43
|
+
const runDate = run
|
|
44
|
+
? new Date(run.created_at).toLocaleDateString("en-US", {
|
|
45
|
+
month: "short",
|
|
46
|
+
day: "numeric",
|
|
47
|
+
hour: "numeric",
|
|
48
|
+
minute: "2-digit",
|
|
49
|
+
})
|
|
50
|
+
: ""
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
|
54
|
+
{/* Header */}
|
|
55
|
+
<div className="shrink-0 px-6 py-3 border-b border-black/[0.06]">
|
|
56
|
+
<div className="max-w-3xl mx-auto flex items-center gap-3">
|
|
57
|
+
<button
|
|
58
|
+
onClick={onBack}
|
|
59
|
+
className="size-7 flex items-center justify-center rounded-md text-[#8e8e8e] hover:text-[#0d0d0d] hover:bg-black/[0.04] transition-colors"
|
|
60
|
+
>
|
|
61
|
+
<ArrowLeft className="size-4" strokeWidth={1.75} />
|
|
62
|
+
</button>
|
|
63
|
+
<div className="min-w-0 flex-1">
|
|
64
|
+
<p className="text-sm font-medium text-[#0d0d0d] truncate">
|
|
65
|
+
{displayName}
|
|
66
|
+
</p>
|
|
67
|
+
<p className="text-[11px] text-[#8e8e8e]">
|
|
68
|
+
{runDate}
|
|
69
|
+
<span className="mx-1">·</span>
|
|
70
|
+
<span className={cn(isRunning && "text-blue-500")}>
|
|
71
|
+
{statusLabel}
|
|
72
|
+
</span>
|
|
73
|
+
</p>
|
|
74
|
+
</div>
|
|
75
|
+
{isRunning && (
|
|
76
|
+
<Loader2 className="size-4 animate-spin text-blue-500 shrink-0" />
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Chat feed — delegated to consumer */}
|
|
82
|
+
{run ? (
|
|
83
|
+
renderChat({
|
|
84
|
+
runId: run.id,
|
|
85
|
+
isRunning,
|
|
86
|
+
isNeedsYou,
|
|
87
|
+
})
|
|
88
|
+
) : (
|
|
89
|
+
<div className="flex-1 flex items-center justify-center">
|
|
90
|
+
<p className="text-sm text-[#9b9b9b]">Run not found</p>
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoutinesGrid — Card grid view for routines.
|
|
3
|
+
* Accepts data and callbacks via props (no Zustand, no Tauri).
|
|
4
|
+
*/
|
|
5
|
+
import { useMemo } from "react"
|
|
6
|
+
import type { Routine } from "./types"
|
|
7
|
+
import { RoutineCard } from "./routine-card"
|
|
8
|
+
|
|
9
|
+
export interface RoutinesGridProps {
|
|
10
|
+
routines: Routine[]
|
|
11
|
+
loading?: boolean
|
|
12
|
+
onSelectRoutine: (routineId: string) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const STATUS_ORDER: Record<string, number> = {
|
|
16
|
+
active: 0,
|
|
17
|
+
needs_setup: 1,
|
|
18
|
+
error: 2,
|
|
19
|
+
paused: 3,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function RoutinesGrid({
|
|
23
|
+
routines,
|
|
24
|
+
loading,
|
|
25
|
+
onSelectRoutine,
|
|
26
|
+
}: RoutinesGridProps) {
|
|
27
|
+
const sorted = useMemo(() => {
|
|
28
|
+
return [...routines].sort((a, b) => {
|
|
29
|
+
const sa = STATUS_ORDER[a.status] ?? 9
|
|
30
|
+
const sb = STATUS_ORDER[b.status] ?? 9
|
|
31
|
+
if (sa !== sb) return sa - sb
|
|
32
|
+
const nameA = (a.name || a.title || "").toLowerCase()
|
|
33
|
+
const nameB = (b.name || b.title || "").toLowerCase()
|
|
34
|
+
return nameA.localeCompare(nameB)
|
|
35
|
+
})
|
|
36
|
+
}, [routines])
|
|
37
|
+
|
|
38
|
+
if (loading && routines.length === 0) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="flex-1 flex items-center justify-center">
|
|
41
|
+
<p className="text-sm text-[#9b9b9b] animate-pulse">
|
|
42
|
+
Loading routines...
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (sorted.length === 0) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex-1 flex flex-col items-center pt-[20vh] gap-4 px-8">
|
|
51
|
+
<div className="space-y-2 text-center max-w-md">
|
|
52
|
+
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
|
53
|
+
Automate recurring work
|
|
54
|
+
</h1>
|
|
55
|
+
<p className="text-sm text-[#5d5d5d]">
|
|
56
|
+
Routines run on a schedule so Houston can work for you
|
|
57
|
+
automatically — daily reports, weekly research, and more.
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div className="flex-1 overflow-y-auto">
|
|
66
|
+
<div className="max-w-4xl mx-auto px-6 py-6">
|
|
67
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
68
|
+
{sorted.map((routine) => (
|
|
69
|
+
<RoutineCard
|
|
70
|
+
key={routine.id}
|
|
71
|
+
routine={routine}
|
|
72
|
+
onClick={() => onSelectRoutine(routine.id)}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@source ".";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Generic routine types — mirrors Houston's Routine model without Tauri/backend coupling.
|
|
2
|
+
|
|
3
|
+
export type TriggerType = "on_approval" | "scheduled" | "periodic" | "manual"
|
|
4
|
+
export type RoutineStatus = "active" | "paused" | "needs_setup" | "error"
|
|
5
|
+
export type ApprovalMode = "manual" | "auto_approve"
|
|
6
|
+
|
|
7
|
+
export type RunStatus =
|
|
8
|
+
| "running"
|
|
9
|
+
| "completed"
|
|
10
|
+
| "failed"
|
|
11
|
+
| "approved"
|
|
12
|
+
| "needs_you"
|
|
13
|
+
| "done"
|
|
14
|
+
| "error"
|
|
15
|
+
|
|
16
|
+
export interface Routine {
|
|
17
|
+
id: string
|
|
18
|
+
project_id: string
|
|
19
|
+
goal_id: string | null
|
|
20
|
+
skill_id: string | null
|
|
21
|
+
name: string
|
|
22
|
+
description: string
|
|
23
|
+
trigger_type: TriggerType
|
|
24
|
+
trigger_config: string
|
|
25
|
+
status: RoutineStatus
|
|
26
|
+
approval_mode: ApprovalMode
|
|
27
|
+
context: string
|
|
28
|
+
run_count: number
|
|
29
|
+
approval_count: number
|
|
30
|
+
last_run_at: string | null
|
|
31
|
+
created_at: string
|
|
32
|
+
updated_at: string
|
|
33
|
+
/** v1 compat — may be present from older DBs */
|
|
34
|
+
title?: string
|
|
35
|
+
skill_name?: string | null
|
|
36
|
+
enabled?: boolean
|
|
37
|
+
is_system?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RoutineRun {
|
|
41
|
+
id: string
|
|
42
|
+
routine_id: string
|
|
43
|
+
project_id: string
|
|
44
|
+
status: RunStatus
|
|
45
|
+
session_id: string | null
|
|
46
|
+
claude_session_id: string | null
|
|
47
|
+
output_files: string | null
|
|
48
|
+
cost_usd: number | null
|
|
49
|
+
duration_ms: number | null
|
|
50
|
+
output_title: string | null
|
|
51
|
+
output_summary: string | null
|
|
52
|
+
feedback_text: string | null
|
|
53
|
+
is_test_run: boolean
|
|
54
|
+
created_at: string
|
|
55
|
+
completed_at: string | null
|
|
56
|
+
approved_at: string | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Skill {
|
|
60
|
+
id: string
|
|
61
|
+
name: string
|
|
62
|
+
description: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const TRIGGER_LABELS: Record<TriggerType, string> = {
|
|
66
|
+
on_approval: "On approval",
|
|
67
|
+
scheduled: "Scheduled",
|
|
68
|
+
periodic: "Periodic",
|
|
69
|
+
manual: "Manual",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Form state for the routine edit form */
|
|
73
|
+
export interface RoutineFormState {
|
|
74
|
+
name: string
|
|
75
|
+
description: string
|
|
76
|
+
context: string
|
|
77
|
+
triggerType: TriggerType
|
|
78
|
+
approvalMode: ApprovalMode
|
|
79
|
+
skillId: string | null
|
|
80
|
+
}
|