botschat 0.1.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/LICENSE +201 -0
- package/README.md +213 -0
- package/migrations/0001_initial.sql +88 -0
- package/migrations/0002_rename_projects_to_channels.sql +53 -0
- package/migrations/0003_messages.sql +14 -0
- package/migrations/0004_jobs.sql +15 -0
- package/migrations/0005_deleted_cron_jobs.sql +6 -0
- package/migrations/0006_tasks_add_model.sql +2 -0
- package/migrations/0007_sessions.sql +25 -0
- package/migrations/0008_remove_openclaw_fields.sql +8 -0
- package/package.json +53 -0
- package/packages/api/package.json +17 -0
- package/packages/api/src/do/connection-do.ts +929 -0
- package/packages/api/src/env.ts +8 -0
- package/packages/api/src/index.ts +297 -0
- package/packages/api/src/routes/agents.ts +68 -0
- package/packages/api/src/routes/auth.ts +105 -0
- package/packages/api/src/routes/channels.ts +185 -0
- package/packages/api/src/routes/jobs.ts +65 -0
- package/packages/api/src/routes/models.ts +22 -0
- package/packages/api/src/routes/pairing.ts +76 -0
- package/packages/api/src/routes/projects.ts +177 -0
- package/packages/api/src/routes/sessions.ts +171 -0
- package/packages/api/src/routes/tasks.ts +375 -0
- package/packages/api/src/routes/upload.ts +52 -0
- package/packages/api/src/utils/auth.ts +101 -0
- package/packages/api/src/utils/id.ts +19 -0
- package/packages/api/tsconfig.json +18 -0
- package/packages/plugin/dist/index.d.ts +19 -0
- package/packages/plugin/dist/index.d.ts.map +1 -0
- package/packages/plugin/dist/index.js +17 -0
- package/packages/plugin/dist/index.js.map +1 -0
- package/packages/plugin/dist/src/accounts.d.ts +12 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
- package/packages/plugin/dist/src/accounts.js +103 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -0
- package/packages/plugin/dist/src/channel.d.ts +206 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -0
- package/packages/plugin/dist/src/channel.js +1248 -0
- package/packages/plugin/dist/src/channel.js.map +1 -0
- package/packages/plugin/dist/src/runtime.d.ts +3 -0
- package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
- package/packages/plugin/dist/src/runtime.js +18 -0
- package/packages/plugin/dist/src/runtime.js.map +1 -0
- package/packages/plugin/dist/src/types.d.ts +179 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -0
- package/packages/plugin/dist/src/types.js +6 -0
- package/packages/plugin/dist/src/types.js.map +1 -0
- package/packages/plugin/dist/src/ws-client.d.ts +51 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
- package/packages/plugin/dist/src/ws-client.js +170 -0
- package/packages/plugin/dist/src/ws-client.js.map +1 -0
- package/packages/plugin/openclaw.plugin.json +11 -0
- package/packages/plugin/package.json +39 -0
- package/packages/plugin/tsconfig.json +20 -0
- package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
- package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
- package/packages/web/dist/index.html +17 -0
- package/packages/web/index.html +16 -0
- package/packages/web/package.json +29 -0
- package/packages/web/postcss.config.js +6 -0
- package/packages/web/src/App.tsx +827 -0
- package/packages/web/src/api.ts +242 -0
- package/packages/web/src/components/ChatWindow.tsx +864 -0
- package/packages/web/src/components/CronDetail.tsx +943 -0
- package/packages/web/src/components/CronSidebar.tsx +123 -0
- package/packages/web/src/components/DebugLogPanel.tsx +258 -0
- package/packages/web/src/components/IconRail.tsx +163 -0
- package/packages/web/src/components/JobList.tsx +120 -0
- package/packages/web/src/components/LoginPage.tsx +178 -0
- package/packages/web/src/components/MessageContent.tsx +1082 -0
- package/packages/web/src/components/ModelSelect.tsx +87 -0
- package/packages/web/src/components/ScheduleEditor.tsx +403 -0
- package/packages/web/src/components/SessionTabs.tsx +246 -0
- package/packages/web/src/components/Sidebar.tsx +331 -0
- package/packages/web/src/components/TaskBar.tsx +413 -0
- package/packages/web/src/components/ThreadPanel.tsx +212 -0
- package/packages/web/src/debug-log.ts +58 -0
- package/packages/web/src/index.css +170 -0
- package/packages/web/src/main.tsx +10 -0
- package/packages/web/src/store.ts +492 -0
- package/packages/web/src/ws.ts +99 -0
- package/packages/web/tailwind.config.js +65 -0
- package/packages/web/tsconfig.json +18 -0
- package/packages/web/vite.config.ts +20 -0
- package/scripts/dev.sh +122 -0
- package/tsconfig.json +18 -0
- package/wrangler.toml +40 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useState, useCallback } from "react";
|
|
2
|
+
import type { ModelInfo } from "../api";
|
|
3
|
+
|
|
4
|
+
type ModelSelectProps = {
|
|
5
|
+
value: string;
|
|
6
|
+
onChange: (modelId: string) => void;
|
|
7
|
+
models: ModelInfo[];
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function ModelSelect({
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
models,
|
|
17
|
+
disabled,
|
|
18
|
+
placeholder = "Select model...",
|
|
19
|
+
compact,
|
|
20
|
+
}: ModelSelectProps) {
|
|
21
|
+
// Try exact match by id first; fall back to matching by model name
|
|
22
|
+
// (e.g. value="gpt-5.2-chat" should resolve to id="azure-gpt52/gpt-5.2-chat")
|
|
23
|
+
const exactMatch = models.find((m) => m.id === value);
|
|
24
|
+
const nameMatch = !exactMatch && value ? models.find((m) => m.name === value) : null;
|
|
25
|
+
const effectiveValue = nameMatch ? nameMatch.id : value;
|
|
26
|
+
|
|
27
|
+
const currentInList = !effectiveValue || models.some((m) => m.id === effectiveValue);
|
|
28
|
+
|
|
29
|
+
// Auto-correct: persist the full model id when we resolved via name match
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (nameMatch && value !== nameMatch.id) {
|
|
32
|
+
onChange(nameMatch.id);
|
|
33
|
+
}
|
|
34
|
+
}, [nameMatch, value, onChange]);
|
|
35
|
+
|
|
36
|
+
const selectRef = useRef<HTMLSelectElement>(null);
|
|
37
|
+
const [selectWidth, setSelectWidth] = useState<number | undefined>(undefined);
|
|
38
|
+
|
|
39
|
+
const displayText = effectiveValue || placeholder;
|
|
40
|
+
|
|
41
|
+
// Measure the text width using Canvas to match the select's actual font
|
|
42
|
+
const updateWidth = useCallback(() => {
|
|
43
|
+
if (!compact || !selectRef.current) return;
|
|
44
|
+
const cs = getComputedStyle(selectRef.current);
|
|
45
|
+
const canvas = document.createElement("canvas");
|
|
46
|
+
const ctx = canvas.getContext("2d");
|
|
47
|
+
if (!ctx) return;
|
|
48
|
+
ctx.font = `${cs.fontSize} ${cs.fontFamily}`;
|
|
49
|
+
const textW = ctx.measureText(displayText).width;
|
|
50
|
+
const padL = parseFloat(cs.paddingLeft) || 6;
|
|
51
|
+
const padR = parseFloat(cs.paddingRight) || 6;
|
|
52
|
+
// padding + ~20px native dropdown arrow + 2px buffer
|
|
53
|
+
setSelectWidth(Math.ceil(textW) + padL + padR + 22);
|
|
54
|
+
}, [compact, displayText]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => { updateWidth(); }, [updateWidth]);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<select
|
|
60
|
+
ref={selectRef}
|
|
61
|
+
value={effectiveValue}
|
|
62
|
+
onChange={(e) => onChange(e.target.value)}
|
|
63
|
+
disabled={disabled}
|
|
64
|
+
className={`rounded-sm focus:outline-none ${compact ? "text-caption py-0.5 px-1.5" : "text-body py-1.5 px-2.5"}`}
|
|
65
|
+
style={{
|
|
66
|
+
background: compact ? "transparent" : "var(--bg-hover)",
|
|
67
|
+
color: effectiveValue ? "var(--text-primary)" : "var(--text-muted)",
|
|
68
|
+
border: compact ? "1px solid transparent" : "1px solid var(--border)",
|
|
69
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
70
|
+
opacity: disabled ? 0.5 : 1,
|
|
71
|
+
width: compact && selectWidth ? selectWidth : undefined,
|
|
72
|
+
maxWidth: "100%",
|
|
73
|
+
fontFamily: "var(--font-mono)",
|
|
74
|
+
textOverflow: "ellipsis",
|
|
75
|
+
overflow: "hidden",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{!effectiveValue && <option value="">{placeholder}</option>}
|
|
79
|
+
{effectiveValue && !currentInList && (
|
|
80
|
+
<option value={effectiveValue}>{effectiveValue}</option>
|
|
81
|
+
)}
|
|
82
|
+
{models.map((m) => (
|
|
83
|
+
<option key={m.id} value={m.id}>{m.id}</option>
|
|
84
|
+
))}
|
|
85
|
+
</select>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// OpenClaw CronService uses structured schedules:
|
|
5
|
+
// { kind: "every", everyMs: number } → interval-based
|
|
6
|
+
// { kind: "at", at: string } → fixed daily time
|
|
7
|
+
//
|
|
8
|
+
// The string format stored in D1 and sent to OpenClaw:
|
|
9
|
+
// "every 30m", "every 2h", "every 10s"
|
|
10
|
+
// "at 09:00", "at 14:30"
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
type ScheduleKind = "every" | "at";
|
|
14
|
+
type IntervalUnit = "s" | "m" | "h";
|
|
15
|
+
|
|
16
|
+
interface ParsedSchedule {
|
|
17
|
+
kind: ScheduleKind;
|
|
18
|
+
// "every" fields
|
|
19
|
+
intervalValue?: number;
|
|
20
|
+
intervalUnit?: IntervalUnit;
|
|
21
|
+
// "at" fields
|
|
22
|
+
atTime?: string; // HH:MM
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse a human-readable schedule string into structured parts */
|
|
26
|
+
function parseSchedule(raw: string): ParsedSchedule | null {
|
|
27
|
+
if (!raw) return null;
|
|
28
|
+
const s = raw.trim().toLowerCase();
|
|
29
|
+
|
|
30
|
+
// Match "every Xh", "every Xm", "every Xs"
|
|
31
|
+
const everyMatch = s.match(/^every\s+(\d+(?:\.\d+)?)\s*(s|m|h)$/);
|
|
32
|
+
if (everyMatch) {
|
|
33
|
+
return {
|
|
34
|
+
kind: "every",
|
|
35
|
+
intervalValue: parseFloat(everyMatch[1]),
|
|
36
|
+
intervalUnit: everyMatch[2] as IntervalUnit,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Match "at HH:MM"
|
|
41
|
+
const atMatch = s.match(/^at\s+(\d{1,2}:\d{2})$/);
|
|
42
|
+
if (atMatch) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "at",
|
|
45
|
+
atTime: atMatch[1].padStart(5, "0"), // ensure "9:00" → "09:00"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build a schedule string from structured parts */
|
|
53
|
+
function buildSchedule(parsed: ParsedSchedule): string {
|
|
54
|
+
if (parsed.kind === "every" && parsed.intervalValue && parsed.intervalUnit) {
|
|
55
|
+
return `every ${parsed.intervalValue}${parsed.intervalUnit}`;
|
|
56
|
+
}
|
|
57
|
+
if (parsed.kind === "at" && parsed.atTime) {
|
|
58
|
+
return `at ${parsed.atTime}`;
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Component
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
type ScheduleEditorProps = {
|
|
68
|
+
value: string;
|
|
69
|
+
onChange: (schedule: string) => void;
|
|
70
|
+
onSave: () => void;
|
|
71
|
+
onCancel: () => void;
|
|
72
|
+
saving?: boolean;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function ScheduleEditor({
|
|
76
|
+
value,
|
|
77
|
+
onChange,
|
|
78
|
+
onSave,
|
|
79
|
+
onCancel,
|
|
80
|
+
saving,
|
|
81
|
+
}: ScheduleEditorProps) {
|
|
82
|
+
const parsed = parseSchedule(value);
|
|
83
|
+
|
|
84
|
+
const [kind, setKind] = useState<ScheduleKind>(parsed?.kind ?? "every");
|
|
85
|
+
const [intervalValue, setIntervalValue] = useState<number>(parsed?.intervalValue ?? 1);
|
|
86
|
+
const [intervalUnit, setIntervalUnit] = useState<IntervalUnit>(parsed?.intervalUnit ?? "h");
|
|
87
|
+
const [atTime, setAtTime] = useState<string>(parsed?.atTime ?? "09:00");
|
|
88
|
+
|
|
89
|
+
// Re-sync when external value changes
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const p = parseSchedule(value);
|
|
92
|
+
if (p) {
|
|
93
|
+
setKind(p.kind);
|
|
94
|
+
if (p.kind === "every") {
|
|
95
|
+
setIntervalValue(p.intervalValue ?? 1);
|
|
96
|
+
setIntervalUnit(p.intervalUnit ?? "h");
|
|
97
|
+
} else {
|
|
98
|
+
setAtTime(p.atTime ?? "09:00");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}, [value]);
|
|
102
|
+
|
|
103
|
+
// Emit the schedule string whenever fields change
|
|
104
|
+
const emitChange = useCallback(
|
|
105
|
+
(k: ScheduleKind, iv: number, iu: IntervalUnit, at: string) => {
|
|
106
|
+
const schedule = buildSchedule(
|
|
107
|
+
k === "every"
|
|
108
|
+
? { kind: "every", intervalValue: iv, intervalUnit: iu }
|
|
109
|
+
: { kind: "at", atTime: at },
|
|
110
|
+
);
|
|
111
|
+
onChange(schedule);
|
|
112
|
+
},
|
|
113
|
+
[onChange],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const handleKindChange = (k: ScheduleKind) => {
|
|
117
|
+
setKind(k);
|
|
118
|
+
emitChange(k, intervalValue, intervalUnit, atTime);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleIntervalValueChange = (v: number) => {
|
|
122
|
+
const clamped = Math.max(1, Math.min(v, 999));
|
|
123
|
+
setIntervalValue(clamped);
|
|
124
|
+
emitChange(kind, clamped, intervalUnit, atTime);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleIntervalUnitChange = (u: IntervalUnit) => {
|
|
128
|
+
setIntervalUnit(u);
|
|
129
|
+
emitChange(kind, intervalValue, u, atTime);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleAtTimeChange = (t: string) => {
|
|
133
|
+
setAtTime(t);
|
|
134
|
+
emitChange(kind, intervalValue, intervalUnit, t);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
138
|
+
if (e.key === "Escape") onCancel();
|
|
139
|
+
if (e.key === "Enter") {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
onSave();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className="flex flex-col gap-2" onKeyDown={handleKeyDown}>
|
|
147
|
+
{/* Row 1: Kind selector tabs */}
|
|
148
|
+
<div className="flex items-center gap-1">
|
|
149
|
+
<KindTab
|
|
150
|
+
active={kind === "every"}
|
|
151
|
+
onClick={() => handleKindChange("every")}
|
|
152
|
+
label="Interval"
|
|
153
|
+
icon={
|
|
154
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
155
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
|
156
|
+
</svg>
|
|
157
|
+
}
|
|
158
|
+
/>
|
|
159
|
+
<KindTab
|
|
160
|
+
active={kind === "at"}
|
|
161
|
+
onClick={() => handleKindChange("at")}
|
|
162
|
+
label="Daily at"
|
|
163
|
+
icon={
|
|
164
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
165
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
166
|
+
</svg>
|
|
167
|
+
}
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
{/* Row 2: Schedule-specific inputs */}
|
|
172
|
+
<div className="flex items-center gap-2">
|
|
173
|
+
{kind === "every" ? (
|
|
174
|
+
<>
|
|
175
|
+
<span className="text-caption flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
|
176
|
+
Every
|
|
177
|
+
</span>
|
|
178
|
+
<input
|
|
179
|
+
type="number"
|
|
180
|
+
min={1}
|
|
181
|
+
max={999}
|
|
182
|
+
value={intervalValue}
|
|
183
|
+
onChange={(e) => handleIntervalValueChange(parseInt(e.target.value, 10) || 1)}
|
|
184
|
+
className="text-body px-2 py-1 rounded-sm focus:outline-none w-16 text-center"
|
|
185
|
+
style={{
|
|
186
|
+
background: "var(--bg-hover)",
|
|
187
|
+
color: "var(--text-primary)",
|
|
188
|
+
border: "1px solid var(--bg-active)",
|
|
189
|
+
}}
|
|
190
|
+
autoFocus
|
|
191
|
+
/>
|
|
192
|
+
<div className="flex items-center gap-0.5">
|
|
193
|
+
<UnitButton
|
|
194
|
+
active={intervalUnit === "m"}
|
|
195
|
+
onClick={() => handleIntervalUnitChange("m")}
|
|
196
|
+
label="min"
|
|
197
|
+
/>
|
|
198
|
+
<UnitButton
|
|
199
|
+
active={intervalUnit === "h"}
|
|
200
|
+
onClick={() => handleIntervalUnitChange("h")}
|
|
201
|
+
label="hr"
|
|
202
|
+
/>
|
|
203
|
+
<UnitButton
|
|
204
|
+
active={intervalUnit === "s"}
|
|
205
|
+
onClick={() => handleIntervalUnitChange("s")}
|
|
206
|
+
label="sec"
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
</>
|
|
210
|
+
) : (
|
|
211
|
+
<>
|
|
212
|
+
<span className="text-caption flex-shrink-0" style={{ color: "var(--text-muted)" }}>
|
|
213
|
+
Daily at
|
|
214
|
+
</span>
|
|
215
|
+
<input
|
|
216
|
+
type="time"
|
|
217
|
+
value={atTime}
|
|
218
|
+
onChange={(e) => handleAtTimeChange(e.target.value)}
|
|
219
|
+
className="text-body px-2 py-1 rounded-sm focus:outline-none"
|
|
220
|
+
style={{
|
|
221
|
+
background: "var(--bg-hover)",
|
|
222
|
+
color: "var(--text-primary)",
|
|
223
|
+
border: "1px solid var(--bg-active)",
|
|
224
|
+
}}
|
|
225
|
+
autoFocus
|
|
226
|
+
/>
|
|
227
|
+
</>
|
|
228
|
+
)}
|
|
229
|
+
|
|
230
|
+
{/* Save / Cancel */}
|
|
231
|
+
<div className="flex items-center gap-1 ml-auto flex-shrink-0">
|
|
232
|
+
<button
|
|
233
|
+
onClick={onSave}
|
|
234
|
+
disabled={saving}
|
|
235
|
+
className="px-2 py-1 text-tiny font-bold text-white rounded-sm disabled:opacity-50"
|
|
236
|
+
style={{ background: "var(--bg-active)" }}
|
|
237
|
+
>
|
|
238
|
+
{saving ? "..." : "Save"}
|
|
239
|
+
</button>
|
|
240
|
+
<button
|
|
241
|
+
onClick={onCancel}
|
|
242
|
+
className="px-2 py-1 text-tiny rounded-sm"
|
|
243
|
+
style={{ color: "var(--text-muted)" }}
|
|
244
|
+
>
|
|
245
|
+
Cancel
|
|
246
|
+
</button>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
{/* Preview */}
|
|
251
|
+
<div className="text-tiny" style={{ color: "var(--text-muted)" }}>
|
|
252
|
+
{kind === "every"
|
|
253
|
+
? `Runs every ${intervalValue} ${intervalUnit === "h" ? "hour" : intervalUnit === "m" ? "minute" : "second"}${intervalValue !== 1 ? "s" : ""}`
|
|
254
|
+
: `Runs daily at ${atTime}`}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Display-only component: shows schedule in a readable format
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
export function ScheduleDisplay({
|
|
265
|
+
schedule,
|
|
266
|
+
onClick,
|
|
267
|
+
}: {
|
|
268
|
+
schedule: string | null;
|
|
269
|
+
onClick: () => void;
|
|
270
|
+
}) {
|
|
271
|
+
if (!schedule) {
|
|
272
|
+
return (
|
|
273
|
+
<span
|
|
274
|
+
className="text-body cursor-pointer hover:underline"
|
|
275
|
+
style={{ color: "var(--text-muted)" }}
|
|
276
|
+
onClick={onClick}
|
|
277
|
+
title="Click to set schedule"
|
|
278
|
+
>
|
|
279
|
+
Not set
|
|
280
|
+
</span>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const parsed = parseSchedule(schedule);
|
|
285
|
+
|
|
286
|
+
if (!parsed) {
|
|
287
|
+
// Unrecognised format — show raw string, let user fix
|
|
288
|
+
return (
|
|
289
|
+
<span
|
|
290
|
+
className="text-body cursor-pointer hover:underline"
|
|
291
|
+
style={{ color: "var(--text-primary)" }}
|
|
292
|
+
onClick={onClick}
|
|
293
|
+
title="Click to edit schedule"
|
|
294
|
+
>
|
|
295
|
+
{schedule}
|
|
296
|
+
</span>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<button
|
|
302
|
+
className="flex items-center gap-1.5 cursor-pointer group"
|
|
303
|
+
onClick={onClick}
|
|
304
|
+
title="Click to edit schedule"
|
|
305
|
+
>
|
|
306
|
+
{parsed.kind === "every" ? (
|
|
307
|
+
<>
|
|
308
|
+
<svg
|
|
309
|
+
className="w-3.5 h-3.5 flex-shrink-0"
|
|
310
|
+
fill="none"
|
|
311
|
+
viewBox="0 0 24 24"
|
|
312
|
+
stroke="currentColor"
|
|
313
|
+
strokeWidth={1.5}
|
|
314
|
+
style={{ color: "var(--text-muted)" }}
|
|
315
|
+
>
|
|
316
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
|
|
317
|
+
</svg>
|
|
318
|
+
<span
|
|
319
|
+
className="text-body group-hover:underline"
|
|
320
|
+
style={{ color: "var(--text-primary)" }}
|
|
321
|
+
>
|
|
322
|
+
Every {parsed.intervalValue}
|
|
323
|
+
{parsed.intervalUnit === "h" ? "h" : parsed.intervalUnit === "m" ? "m" : "s"}
|
|
324
|
+
</span>
|
|
325
|
+
</>
|
|
326
|
+
) : (
|
|
327
|
+
<>
|
|
328
|
+
<svg
|
|
329
|
+
className="w-3.5 h-3.5 flex-shrink-0"
|
|
330
|
+
fill="none"
|
|
331
|
+
viewBox="0 0 24 24"
|
|
332
|
+
stroke="currentColor"
|
|
333
|
+
strokeWidth={1.5}
|
|
334
|
+
style={{ color: "var(--text-muted)" }}
|
|
335
|
+
>
|
|
336
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
337
|
+
</svg>
|
|
338
|
+
<span
|
|
339
|
+
className="text-body group-hover:underline"
|
|
340
|
+
style={{ color: "var(--text-primary)" }}
|
|
341
|
+
>
|
|
342
|
+
Daily at {parsed.atTime}
|
|
343
|
+
</span>
|
|
344
|
+
</>
|
|
345
|
+
)}
|
|
346
|
+
</button>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Sub-components
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
function KindTab({
|
|
355
|
+
active,
|
|
356
|
+
onClick,
|
|
357
|
+
label,
|
|
358
|
+
icon,
|
|
359
|
+
}: {
|
|
360
|
+
active: boolean;
|
|
361
|
+
onClick: () => void;
|
|
362
|
+
label: string;
|
|
363
|
+
icon: React.ReactNode;
|
|
364
|
+
}) {
|
|
365
|
+
return (
|
|
366
|
+
<button
|
|
367
|
+
onClick={onClick}
|
|
368
|
+
className="flex items-center gap-1.5 px-2.5 py-1 text-caption rounded-sm transition-colors"
|
|
369
|
+
style={{
|
|
370
|
+
background: active ? "rgba(29,155,209,0.15)" : "transparent",
|
|
371
|
+
color: active ? "var(--text-link)" : "var(--text-muted)",
|
|
372
|
+
border: active ? "1px solid rgba(29,155,209,0.3)" : "1px solid transparent",
|
|
373
|
+
}}
|
|
374
|
+
>
|
|
375
|
+
{icon}
|
|
376
|
+
{label}
|
|
377
|
+
</button>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function UnitButton({
|
|
382
|
+
active,
|
|
383
|
+
onClick,
|
|
384
|
+
label,
|
|
385
|
+
}: {
|
|
386
|
+
active: boolean;
|
|
387
|
+
onClick: () => void;
|
|
388
|
+
label: string;
|
|
389
|
+
}) {
|
|
390
|
+
return (
|
|
391
|
+
<button
|
|
392
|
+
onClick={onClick}
|
|
393
|
+
className="px-2 py-1 text-caption rounded-sm transition-colors"
|
|
394
|
+
style={{
|
|
395
|
+
background: active ? "var(--bg-active)" : "var(--bg-hover)",
|
|
396
|
+
color: active ? "#fff" : "var(--text-secondary)",
|
|
397
|
+
border: active ? "1px solid var(--bg-active)" : "1px solid var(--border)",
|
|
398
|
+
}}
|
|
399
|
+
>
|
|
400
|
+
{label}
|
|
401
|
+
</button>
|
|
402
|
+
);
|
|
403
|
+
}
|