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.
Files changed (88) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +213 -0
  3. package/migrations/0001_initial.sql +88 -0
  4. package/migrations/0002_rename_projects_to_channels.sql +53 -0
  5. package/migrations/0003_messages.sql +14 -0
  6. package/migrations/0004_jobs.sql +15 -0
  7. package/migrations/0005_deleted_cron_jobs.sql +6 -0
  8. package/migrations/0006_tasks_add_model.sql +2 -0
  9. package/migrations/0007_sessions.sql +25 -0
  10. package/migrations/0008_remove_openclaw_fields.sql +8 -0
  11. package/package.json +53 -0
  12. package/packages/api/package.json +17 -0
  13. package/packages/api/src/do/connection-do.ts +929 -0
  14. package/packages/api/src/env.ts +8 -0
  15. package/packages/api/src/index.ts +297 -0
  16. package/packages/api/src/routes/agents.ts +68 -0
  17. package/packages/api/src/routes/auth.ts +105 -0
  18. package/packages/api/src/routes/channels.ts +185 -0
  19. package/packages/api/src/routes/jobs.ts +65 -0
  20. package/packages/api/src/routes/models.ts +22 -0
  21. package/packages/api/src/routes/pairing.ts +76 -0
  22. package/packages/api/src/routes/projects.ts +177 -0
  23. package/packages/api/src/routes/sessions.ts +171 -0
  24. package/packages/api/src/routes/tasks.ts +375 -0
  25. package/packages/api/src/routes/upload.ts +52 -0
  26. package/packages/api/src/utils/auth.ts +101 -0
  27. package/packages/api/src/utils/id.ts +19 -0
  28. package/packages/api/tsconfig.json +18 -0
  29. package/packages/plugin/dist/index.d.ts +19 -0
  30. package/packages/plugin/dist/index.d.ts.map +1 -0
  31. package/packages/plugin/dist/index.js +17 -0
  32. package/packages/plugin/dist/index.js.map +1 -0
  33. package/packages/plugin/dist/src/accounts.d.ts +12 -0
  34. package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
  35. package/packages/plugin/dist/src/accounts.js +103 -0
  36. package/packages/plugin/dist/src/accounts.js.map +1 -0
  37. package/packages/plugin/dist/src/channel.d.ts +206 -0
  38. package/packages/plugin/dist/src/channel.d.ts.map +1 -0
  39. package/packages/plugin/dist/src/channel.js +1248 -0
  40. package/packages/plugin/dist/src/channel.js.map +1 -0
  41. package/packages/plugin/dist/src/runtime.d.ts +3 -0
  42. package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
  43. package/packages/plugin/dist/src/runtime.js +18 -0
  44. package/packages/plugin/dist/src/runtime.js.map +1 -0
  45. package/packages/plugin/dist/src/types.d.ts +179 -0
  46. package/packages/plugin/dist/src/types.d.ts.map +1 -0
  47. package/packages/plugin/dist/src/types.js +6 -0
  48. package/packages/plugin/dist/src/types.js.map +1 -0
  49. package/packages/plugin/dist/src/ws-client.d.ts +51 -0
  50. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
  51. package/packages/plugin/dist/src/ws-client.js +170 -0
  52. package/packages/plugin/dist/src/ws-client.js.map +1 -0
  53. package/packages/plugin/openclaw.plugin.json +11 -0
  54. package/packages/plugin/package.json +39 -0
  55. package/packages/plugin/tsconfig.json +20 -0
  56. package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
  57. package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
  58. package/packages/web/dist/index.html +17 -0
  59. package/packages/web/index.html +16 -0
  60. package/packages/web/package.json +29 -0
  61. package/packages/web/postcss.config.js +6 -0
  62. package/packages/web/src/App.tsx +827 -0
  63. package/packages/web/src/api.ts +242 -0
  64. package/packages/web/src/components/ChatWindow.tsx +864 -0
  65. package/packages/web/src/components/CronDetail.tsx +943 -0
  66. package/packages/web/src/components/CronSidebar.tsx +123 -0
  67. package/packages/web/src/components/DebugLogPanel.tsx +258 -0
  68. package/packages/web/src/components/IconRail.tsx +163 -0
  69. package/packages/web/src/components/JobList.tsx +120 -0
  70. package/packages/web/src/components/LoginPage.tsx +178 -0
  71. package/packages/web/src/components/MessageContent.tsx +1082 -0
  72. package/packages/web/src/components/ModelSelect.tsx +87 -0
  73. package/packages/web/src/components/ScheduleEditor.tsx +403 -0
  74. package/packages/web/src/components/SessionTabs.tsx +246 -0
  75. package/packages/web/src/components/Sidebar.tsx +331 -0
  76. package/packages/web/src/components/TaskBar.tsx +413 -0
  77. package/packages/web/src/components/ThreadPanel.tsx +212 -0
  78. package/packages/web/src/debug-log.ts +58 -0
  79. package/packages/web/src/index.css +170 -0
  80. package/packages/web/src/main.tsx +10 -0
  81. package/packages/web/src/store.ts +492 -0
  82. package/packages/web/src/ws.ts +99 -0
  83. package/packages/web/tailwind.config.js +65 -0
  84. package/packages/web/tsconfig.json +18 -0
  85. package/packages/web/vite.config.ts +20 -0
  86. package/scripts/dev.sh +122 -0
  87. package/tsconfig.json +18 -0
  88. package/wrangler.toml +40 -0
@@ -0,0 +1,123 @@
1
+ import React from "react";
2
+ import { useAppState, useAppDispatch } from "../store";
3
+
4
+ function relativeTime(ts: number): string {
5
+ const now = Date.now() / 1000;
6
+ const diff = now - ts;
7
+ if (diff < 60) return "just now";
8
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
9
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
10
+ return `${Math.floor(diff / 86400)}d ago`;
11
+ }
12
+
13
+ export function CronSidebar() {
14
+ const state = useAppState();
15
+ const dispatch = useAppDispatch();
16
+
17
+ const handleSelect = (taskId: string) => {
18
+ dispatch({ type: "SELECT_CRON_TASK", taskId });
19
+ };
20
+
21
+ return (
22
+ <div
23
+ className="flex flex-col h-full"
24
+ style={{ width: 220, minWidth: 160, background: "var(--bg-secondary)", borderRight: "1px solid var(--border)" }}
25
+ >
26
+ {/* Header */}
27
+ <div className="px-4 py-3 flex items-center gap-2">
28
+ <span className="text-[--text-sidebar-active] font-bold text-h2 truncate flex-1">
29
+ Automations
30
+ </span>
31
+ </div>
32
+
33
+ {/* Connection status */}
34
+ <div className="px-4 pb-2">
35
+ <div className="flex items-center gap-1.5">
36
+ <div
37
+ className="w-2 h-2 rounded-full"
38
+ style={{ background: state.openclawConnected ? "var(--accent-green)" : "var(--accent-red)" }}
39
+ />
40
+ <span className="text-tiny text-[--text-muted]">
41
+ {state.openclawConnected ? "OpenClaw connected" : "OpenClaw offline"}
42
+ </span>
43
+ </div>
44
+ </div>
45
+
46
+ {/* Task count */}
47
+ <div className="px-4 pb-2">
48
+ <span className="text-tiny text-[--text-muted]">
49
+ {state.cronTasks.length} cron job{state.cronTasks.length !== 1 ? "s" : ""}
50
+ </span>
51
+ </div>
52
+
53
+ {/* Job list */}
54
+ <div className="flex-1 overflow-y-auto sidebar-scroll">
55
+ {state.cronTasks.length === 0 ? (
56
+ <div className="px-4 py-8 text-center">
57
+ <svg
58
+ className="w-10 h-10 mx-auto mb-3"
59
+ fill="none"
60
+ viewBox="0 0 24 24"
61
+ stroke="currentColor"
62
+ strokeWidth={1}
63
+ style={{ color: "var(--text-muted)" }}
64
+ >
65
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
66
+ </svg>
67
+ <p className="text-caption text-[--text-muted]">
68
+ No automations yet.
69
+ </p>
70
+ <p className="text-tiny text-[--text-muted] mt-1">
71
+ Cron jobs from OpenClaw will appear here automatically.
72
+ </p>
73
+ </div>
74
+ ) : (
75
+ state.cronTasks.map((task) => {
76
+ const isSelected = state.selectedCronTaskId === task.id;
77
+ const isEnabled = task.enabled;
78
+ // Determine status dot color
79
+ let dotColor = "var(--accent-green)"; // enabled
80
+ if (!isEnabled) dotColor = "var(--text-muted)"; // paused
81
+
82
+ return (
83
+ <button
84
+ key={task.id}
85
+ onClick={() => handleSelect(task.id)}
86
+ className="w-full text-left py-2 transition-colors"
87
+ style={{
88
+ paddingLeft: isSelected ? 13 : 16,
89
+ paddingRight: 16,
90
+ background: isSelected ? "var(--bg-hover)" : undefined,
91
+ borderLeft: isSelected ? "3px solid var(--bg-active)" : "3px solid transparent",
92
+ color: isSelected ? "var(--text-sidebar-active)" : "var(--text-sidebar)",
93
+ }}
94
+ onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = "var(--sidebar-hover)"; }}
95
+ onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = isSelected ? "var(--bg-hover)" : ""; }}
96
+ >
97
+ <div className="flex items-center gap-2 min-w-0">
98
+ <div
99
+ className="w-2 h-2 rounded-full flex-shrink-0"
100
+ style={{ background: dotColor }}
101
+ />
102
+ <span className={`text-body truncate ${isSelected ? "font-bold" : ""}`}>
103
+ {task.name}
104
+ </span>
105
+ </div>
106
+ <div className="flex items-center gap-2 mt-0.5 pl-4">
107
+ <span className="text-tiny truncate" style={{ color: "var(--text-muted)" }}>
108
+ {task.schedule ?? "no schedule"}
109
+ </span>
110
+ {!isEnabled && (
111
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>
112
+ paused
113
+ </span>
114
+ )}
115
+ </div>
116
+ </button>
117
+ );
118
+ })
119
+ )}
120
+ </div>
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,258 @@
1
+ import React, { useEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import { getLogEntries, subscribeLog, clearLog, type LogEntry, type LogLevel } from "../debug-log";
3
+
4
+ const LEVEL_COLORS: Record<LogLevel, string> = {
5
+ info: "var(--text-muted)",
6
+ warn: "var(--accent-yellow)",
7
+ error: "var(--accent-red)",
8
+ "ws-in": "#6BCB77",
9
+ "ws-out": "#4D96FF",
10
+ api: "#C77DFF",
11
+ };
12
+
13
+ const LEVEL_LABELS: Record<LogLevel, string> = {
14
+ info: "INF",
15
+ warn: "WRN",
16
+ error: "ERR",
17
+ "ws-in": "WS\u2193",
18
+ "ws-out": "WS\u2191",
19
+ api: "API",
20
+ };
21
+
22
+ function formatTs(ts: number): string {
23
+ const d = new Date(ts);
24
+ return d.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) +
25
+ "." + String(d.getMilliseconds()).padStart(3, "0");
26
+ }
27
+
28
+ export function DebugLogPanel() {
29
+ const entries = useSyncExternalStore(subscribeLog, getLogEntries);
30
+ const [open, setOpen] = useState(false);
31
+ const [filter, setFilter] = useState<LogLevel | "all">("all");
32
+ const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
33
+ const scrollRef = useRef<HTMLDivElement>(null);
34
+ const autoScrollRef = useRef(true);
35
+
36
+ // Auto-scroll to bottom when new entries arrive
37
+ useEffect(() => {
38
+ if (open && autoScrollRef.current && scrollRef.current) {
39
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
40
+ }
41
+ }, [entries.length, open]);
42
+
43
+ const handleScroll = () => {
44
+ if (!scrollRef.current) return;
45
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
46
+ autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 40;
47
+ };
48
+
49
+ const toggleExpand = (id: number) => {
50
+ setExpandedIds((prev) => {
51
+ const next = new Set(prev);
52
+ if (next.has(id)) next.delete(id);
53
+ else next.add(id);
54
+ return next;
55
+ });
56
+ };
57
+
58
+ const filtered = filter === "all" ? entries : entries.filter((e) => e.level === filter);
59
+ const entryCount = entries.length;
60
+
61
+ return (
62
+ <div
63
+ style={{
64
+ flexShrink: 0,
65
+ fontFamily: "var(--font-mono)",
66
+ fontSize: 12,
67
+ lineHeight: 1.5,
68
+ }}
69
+ >
70
+ {/* Toggle bar */}
71
+ <div
72
+ onClick={() => setOpen(!open)}
73
+ style={{
74
+ display: "flex",
75
+ alignItems: "center",
76
+ gap: 8,
77
+ padding: "3px 12px",
78
+ background: "var(--bg-secondary)",
79
+ borderTop: "1px solid var(--border)",
80
+ cursor: "pointer",
81
+ userSelect: "none",
82
+ }}
83
+ >
84
+ <svg
85
+ width={10} height={10}
86
+ viewBox="0 0 10 10"
87
+ style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.15s", flexShrink: 0 }}
88
+ >
89
+ <path d="M1 7L5 3l4 4" stroke="var(--text-muted)" strokeWidth={1.5} fill="none" />
90
+ </svg>
91
+ <span style={{ color: "var(--text-muted)", fontWeight: 600, fontSize: 11 }}>
92
+ Debug Log
93
+ </span>
94
+ <span style={{ color: "var(--text-muted)", fontSize: 10 }}>
95
+ ({entryCount})
96
+ </span>
97
+ {/* Quick filter pills when open */}
98
+ {open && (
99
+ <div style={{ display: "flex", gap: 2, marginLeft: 8 }} onClick={(e) => e.stopPropagation()}>
100
+ {(["all", "ws-in", "ws-out", "api", "info", "warn", "error"] as const).map((lvl) => (
101
+ <button
102
+ key={lvl}
103
+ onClick={() => setFilter(lvl)}
104
+ style={{
105
+ padding: "1px 6px",
106
+ borderRadius: 3,
107
+ border: "none",
108
+ fontSize: 10,
109
+ fontFamily: "var(--font-mono)",
110
+ cursor: "pointer",
111
+ background: filter === lvl ? "var(--bg-active)" : "var(--bg-hover)",
112
+ color: filter === lvl ? "#fff" : (lvl === "all" ? "var(--text-muted)" : LEVEL_COLORS[lvl as LogLevel]),
113
+ }}
114
+ >
115
+ {lvl === "all" ? "ALL" : LEVEL_LABELS[lvl as LogLevel]}
116
+ </button>
117
+ ))}
118
+ </div>
119
+ )}
120
+ {open && (
121
+ <button
122
+ onClick={(e) => { e.stopPropagation(); clearLog(); }}
123
+ style={{
124
+ marginLeft: "auto",
125
+ padding: "1px 8px",
126
+ borderRadius: 3,
127
+ border: "none",
128
+ fontSize: 10,
129
+ fontFamily: "var(--font-mono)",
130
+ cursor: "pointer",
131
+ background: "var(--bg-hover)",
132
+ color: "var(--text-muted)",
133
+ }}
134
+ >
135
+ Clear
136
+ </button>
137
+ )}
138
+ </div>
139
+
140
+ {/* Log content */}
141
+ {open && (
142
+ <div
143
+ ref={scrollRef}
144
+ onScroll={handleScroll}
145
+ style={{
146
+ height: 220,
147
+ overflowY: "auto",
148
+ overflowX: "hidden",
149
+ background: "var(--bg-primary)",
150
+ borderTop: "1px solid var(--border)",
151
+ }}
152
+ >
153
+ {filtered.length === 0 && (
154
+ <div style={{ padding: "16px 12px", color: "var(--text-muted)", textAlign: "center" }}>
155
+ No log entries yet.
156
+ </div>
157
+ )}
158
+ {filtered.map((entry) => (
159
+ <LogRow
160
+ key={entry.id}
161
+ entry={entry}
162
+ expanded={expandedIds.has(entry.id)}
163
+ onToggleExpand={() => toggleExpand(entry.id)}
164
+ />
165
+ ))}
166
+ </div>
167
+ )}
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function LogRow({
173
+ entry,
174
+ expanded,
175
+ onToggleExpand,
176
+ }: {
177
+ entry: LogEntry;
178
+ expanded: boolean;
179
+ onToggleExpand: () => void;
180
+ }) {
181
+ const levelColor = LEVEL_COLORS[entry.level];
182
+ const levelLabel = LEVEL_LABELS[entry.level];
183
+
184
+ return (
185
+ <div
186
+ style={{
187
+ padding: "1px 12px",
188
+ borderBottom: "1px solid var(--border)",
189
+ wordBreak: "break-all",
190
+ }}
191
+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--bg-hover)")}
192
+ onMouseLeave={(e) => (e.currentTarget.style.background = "")}
193
+ >
194
+ <div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
195
+ {/* Timestamp */}
196
+ <span style={{ color: "var(--text-muted)", flexShrink: 0, width: 85 }}>
197
+ {formatTs(entry.ts)}
198
+ </span>
199
+ {/* Level badge */}
200
+ <span
201
+ style={{
202
+ color: levelColor,
203
+ fontWeight: 700,
204
+ flexShrink: 0,
205
+ width: 28,
206
+ textAlign: "center",
207
+ }}
208
+ >
209
+ {levelLabel}
210
+ </span>
211
+ {/* Tag */}
212
+ <span style={{ color: "var(--text-secondary)", flexShrink: 0, minWidth: 50 }}>
213
+ [{entry.tag}]
214
+ </span>
215
+ {/* Message */}
216
+ <span style={{ color: "var(--text-primary)", flex: 1 }}>
217
+ {entry.message}
218
+ </span>
219
+ {/* Expand toggle for detail */}
220
+ {entry.detail && (
221
+ <button
222
+ onClick={onToggleExpand}
223
+ style={{
224
+ border: "none",
225
+ background: "none",
226
+ cursor: "pointer",
227
+ color: "var(--text-muted)",
228
+ fontSize: 10,
229
+ padding: "0 4px",
230
+ flexShrink: 0,
231
+ }}
232
+ >
233
+ {expanded ? "\u25BC" : "\u25B6"}
234
+ </button>
235
+ )}
236
+ </div>
237
+ {/* Expanded detail */}
238
+ {expanded && entry.detail && (
239
+ <pre
240
+ style={{
241
+ margin: "2px 0 4px 121px",
242
+ padding: "4px 8px",
243
+ background: "var(--code-bg)",
244
+ borderRadius: 3,
245
+ color: "var(--text-secondary)",
246
+ fontSize: 11,
247
+ whiteSpace: "pre-wrap",
248
+ wordBreak: "break-all",
249
+ maxHeight: 200,
250
+ overflowY: "auto",
251
+ }}
252
+ >
253
+ {entry.detail}
254
+ </pre>
255
+ )}
256
+ </div>
257
+ );
258
+ }
@@ -0,0 +1,163 @@
1
+ import React from "react";
2
+ import { useAppState, useAppDispatch, type ActiveView } from "../store";
3
+ import { setToken } from "../api";
4
+ import { dlog } from "../debug-log";
5
+
6
+ type IconRailProps = {
7
+ onToggleTheme: () => void;
8
+ onOpenSettings: () => void;
9
+ theme: "dark" | "light";
10
+ };
11
+
12
+ export function IconRail({ onToggleTheme, onOpenSettings, theme }: IconRailProps) {
13
+ const state = useAppState();
14
+ const dispatch = useAppDispatch();
15
+
16
+ const handleLogout = () => {
17
+ dlog.info("Auth", `Logout — user ${state.user?.email}`);
18
+ setToken(null);
19
+ dispatch({ type: "LOGOUT" });
20
+ };
21
+
22
+ const setView = (view: ActiveView) => {
23
+ dlog.info("Nav", `Switch view → ${view}`);
24
+ dispatch({ type: "SET_ACTIVE_VIEW", view });
25
+ };
26
+
27
+ const userInitial = state.user?.displayName?.[0]?.toUpperCase()
28
+ ?? state.user?.email?.[0]?.toUpperCase()
29
+ ?? "?";
30
+
31
+ return (
32
+ <div
33
+ className="flex flex-col items-center py-3 gap-2 h-full"
34
+ style={{ width: 68, background: "var(--bg-primary)", borderRight: "1px solid var(--border)" }}
35
+ >
36
+ {/* Workspace icon */}
37
+ <button
38
+ className="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm hover:rounded-xl transition-all"
39
+ style={{ background: "#1264A3" }}
40
+ title="BotsChat"
41
+ >
42
+ BC
43
+ </button>
44
+
45
+ <div className="w-8 border-t my-1" style={{ borderColor: "var(--sidebar-divider)" }} />
46
+
47
+ {/* Home */}
48
+ <RailIcon
49
+ label="Home"
50
+ active={false}
51
+ icon={
52
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
53
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
54
+ </svg>
55
+ }
56
+ />
57
+
58
+ {/* Messages */}
59
+ <RailIcon
60
+ label="Messages"
61
+ active={state.activeView === "messages"}
62
+ onClick={() => setView("messages")}
63
+ icon={
64
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
65
+ <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
66
+ </svg>
67
+ }
68
+ />
69
+
70
+ {/* Automations */}
71
+ <RailIcon
72
+ label="Automations"
73
+ active={state.activeView === "automations"}
74
+ onClick={() => setView("automations")}
75
+ icon={
76
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
77
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
78
+ </svg>
79
+ }
80
+ />
81
+
82
+ <div className="flex-1" />
83
+
84
+ {/* Settings */}
85
+ <RailIcon
86
+ label="Settings"
87
+ active={false}
88
+ onClick={onOpenSettings}
89
+ icon={
90
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
91
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
92
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
93
+ </svg>
94
+ }
95
+ />
96
+
97
+ {/* Theme toggle */}
98
+ <RailIcon
99
+ label={theme === "dark" ? "Light mode" : "Dark mode"}
100
+ active={false}
101
+ onClick={onToggleTheme}
102
+ icon={
103
+ theme === "dark" ? (
104
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
105
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
106
+ </svg>
107
+ ) : (
108
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
109
+ <path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
110
+ </svg>
111
+ )
112
+ }
113
+ />
114
+
115
+ {/* User avatar */}
116
+ <button
117
+ onClick={handleLogout}
118
+ className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold text-white mt-1"
119
+ style={{ background: "#9B59B6" }}
120
+ title={`${state.user?.displayName ?? state.user?.email} (click to logout)`}
121
+ >
122
+ {userInitial}
123
+ </button>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ function RailIcon({
129
+ label,
130
+ active,
131
+ icon,
132
+ onClick,
133
+ }: {
134
+ label: string;
135
+ active: boolean;
136
+ icon: React.ReactNode;
137
+ onClick?: () => void;
138
+ }) {
139
+ return (
140
+ <div className="relative flex items-center justify-center">
141
+ {/* Active indicator - left bar */}
142
+ {active && (
143
+ <div
144
+ className="absolute left-0 w-[3px] h-5 rounded-r-sm"
145
+ style={{ left: -4, background: "var(--text-sidebar-active)" }}
146
+ />
147
+ )}
148
+ <button
149
+ onClick={onClick}
150
+ className={`w-9 h-9 rounded-lg flex items-center justify-center transition-colors ${
151
+ active ? "text-[--text-sidebar-active]" : "text-[--text-sidebar] hover:text-[--text-sidebar-active]"
152
+ }`}
153
+ style={active ? { background: "var(--sidebar-hover)" } : undefined}
154
+ onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--sidebar-hover)"; }}
155
+ onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = ""; }}
156
+ title={label}
157
+ aria-label={label}
158
+ >
159
+ {icon}
160
+ </button>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,120 @@
1
+ import React from "react";
2
+ import type { Job } from "../api";
3
+
4
+ type JobListProps = {
5
+ jobs: Job[];
6
+ selectedJobId: string | null;
7
+ onSelectJob: (jobId: string) => void;
8
+ };
9
+
10
+ function statusLabel(status: string): string {
11
+ switch (status) {
12
+ case "ok": return "OK";
13
+ case "error": return "ERR";
14
+ case "skipped": return "SKIP";
15
+ case "running": return "RUN";
16
+ default: return status.toUpperCase();
17
+ }
18
+ }
19
+
20
+ function statusColors(status: string): { bg: string; fg: string } {
21
+ switch (status) {
22
+ case "ok": return { bg: "rgba(43,172,118,0.15)", fg: "var(--accent-green)" };
23
+ case "error": return { bg: "rgba(224,30,90,0.15)", fg: "var(--accent-red)" };
24
+ case "running": return { bg: "rgba(29,155,209,0.15)", fg: "var(--text-link)" };
25
+ default: return { bg: "rgba(232,162,48,0.15)", fg: "var(--accent-yellow)" };
26
+ }
27
+ }
28
+
29
+ export function JobList({ jobs, selectedJobId, onSelectJob }: JobListProps) {
30
+ if (jobs.length === 0) {
31
+ return (
32
+ <div
33
+ className="flex items-center justify-center"
34
+ style={{
35
+ width: 192,
36
+ borderRight: "1px solid var(--border)",
37
+ background: "var(--bg-surface)",
38
+ }}
39
+ >
40
+ <div className="text-center p-4">
41
+ <svg
42
+ className="w-8 h-8 mx-auto mb-2"
43
+ fill="none"
44
+ viewBox="0 0 24 24"
45
+ stroke="currentColor"
46
+ strokeWidth={1.5}
47
+ style={{ color: "var(--text-muted)" }}
48
+ >
49
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
50
+ </svg>
51
+ <p className="text-tiny" style={{ color: "var(--text-muted)" }}>
52
+ No runs yet.
53
+ <br />
54
+ Waiting for schedule...
55
+ </p>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
60
+
61
+ return (
62
+ <div
63
+ className="overflow-y-auto"
64
+ style={{
65
+ width: 192,
66
+ borderRight: "1px solid var(--border)",
67
+ background: "var(--bg-surface)",
68
+ }}
69
+ >
70
+ <div className="px-3 py-2" style={{ borderBottom: "1px solid var(--border)" }}>
71
+ <span className="text-tiny uppercase tracking-wider font-bold" style={{ color: "var(--text-muted)" }}>
72
+ Job History
73
+ </span>
74
+ <span className="text-tiny ml-1" style={{ color: "var(--text-muted)" }}>
75
+ ({jobs.length})
76
+ </span>
77
+ </div>
78
+ {jobs.map((job, idx) => {
79
+ const colors = statusColors(job.status);
80
+ const displayNum = job.number || jobs.length - idx;
81
+ return (
82
+ <button
83
+ key={job.id}
84
+ onClick={() => onSelectJob(job.id)}
85
+ className={`w-full text-left px-3 py-2 hover:bg-[--bg-hover] transition-colors ${
86
+ selectedJobId === job.id ? "bg-[--bg-hover]" : ""
87
+ }`}
88
+ style={{
89
+ borderBottom: "1px solid var(--border)",
90
+ ...(selectedJobId === job.id ? { borderLeft: "3px solid var(--bg-active)" } : {}),
91
+ }}
92
+ >
93
+ <div className="flex items-center justify-between">
94
+ <span className="text-tiny font-mono" style={{ color: "var(--text-muted)" }}>
95
+ #{displayNum}
96
+ </span>
97
+ <span
98
+ className="text-tiny px-1.5 py-0.5 rounded-sm font-bold"
99
+ style={{ background: colors.bg, color: colors.fg }}
100
+ >
101
+ {statusLabel(job.status)}
102
+ </span>
103
+ </div>
104
+ <div className="text-tiny mt-0.5" style={{ color: "var(--text-muted)" }}>
105
+ {job.time}
106
+ {job.durationMs != null && (
107
+ <span className="ml-1">({(job.durationMs / 1000).toFixed(1)}s)</span>
108
+ )}
109
+ </div>
110
+ {job.summary && (
111
+ <div className="text-caption mt-1 truncate" style={{ color: "var(--text-secondary)" }}>
112
+ {job.summary}
113
+ </div>
114
+ )}
115
+ </button>
116
+ );
117
+ })}
118
+ </div>
119
+ );
120
+ }